1282 lines
53 KiB
TypeScript
1282 lines
53 KiB
TypeScript
/**
|
||
* Unified schema file — all tables defined here.
|
||
* drizzle-kit reads this file directly (avoids CJS/ESM extension issues).
|
||
*/
|
||
|
||
import { sql } from 'drizzle-orm';
|
||
import type { AnyPgColumn } from 'drizzle-orm/pg-core';
|
||
import {
|
||
pgTable,
|
||
pgEnum,
|
||
text,
|
||
timestamp,
|
||
boolean,
|
||
uuid,
|
||
jsonb,
|
||
index,
|
||
uniqueIndex,
|
||
unique,
|
||
check,
|
||
real,
|
||
integer,
|
||
bigint,
|
||
customType,
|
||
} from 'drizzle-orm/pg-core';
|
||
|
||
// ─── Auth (BetterAuth-compatible) ────────────────────────────────────────────
|
||
|
||
export const users = pgTable('users', {
|
||
id: text('id').primaryKey(),
|
||
name: text('name').notNull(),
|
||
email: text('email').notNull().unique(),
|
||
emailVerified: boolean('email_verified').notNull().default(false),
|
||
image: text('image'),
|
||
role: text('role').notNull().default('member'),
|
||
banned: boolean('banned').default(false),
|
||
banReason: text('ban_reason'),
|
||
banExpires: timestamp('ban_expires', { withTimezone: true }),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
});
|
||
|
||
export const sessions = pgTable(
|
||
'sessions',
|
||
{
|
||
id: text('id').primaryKey(),
|
||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||
token: text('token').notNull().unique(),
|
||
ipAddress: text('ip_address'),
|
||
userAgent: text('user_agent'),
|
||
userId: text('user_id')
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: 'cascade' }),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
// Auth hot path: look up all sessions for a user (BetterAuth session list).
|
||
index('sessions_user_id_idx').on(t.userId),
|
||
// Session expiry cleanup queries.
|
||
index('sessions_expires_at_idx').on(t.expiresAt),
|
||
],
|
||
);
|
||
|
||
export const accounts = pgTable(
|
||
'accounts',
|
||
{
|
||
id: text('id').primaryKey(),
|
||
accountId: text('account_id').notNull(),
|
||
providerId: text('provider_id').notNull(),
|
||
// better-auth >=1.7 requires an issuer on every account row: credential
|
||
// sign-up writes the synthetic 'local:credential', OAuth rows carry the
|
||
// provider's real issuer, and sign-in filters on (providerId, issuer).
|
||
// Nullable because the 1.5.x line this repo's lockfile resolves to does
|
||
// not know the field — 1.5 ignores it, 1.7 populates it (#1395).
|
||
issuer: text('issuer'),
|
||
userId: text('user_id')
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: 'cascade' }),
|
||
accessToken: text('access_token'),
|
||
refreshToken: text('refresh_token'),
|
||
idToken: text('id_token'),
|
||
accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }),
|
||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
|
||
scope: text('scope'),
|
||
password: text('password'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
// BetterAuth looks up accounts by (provider_id, account_id) on OAuth callback.
|
||
index('accounts_provider_account_idx').on(t.providerId, t.accountId),
|
||
// Also used in session validation to find linked accounts for a user.
|
||
index('accounts_user_id_idx').on(t.userId),
|
||
],
|
||
);
|
||
|
||
export const verifications = pgTable('verifications', {
|
||
id: text('id').primaryKey(),
|
||
identifier: text('identifier').notNull(),
|
||
value: text('value').notNull(),
|
||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
});
|
||
|
||
// ─── Admin API Tokens ───────────────────────────────────────────────────────
|
||
|
||
export const adminTokens = pgTable(
|
||
'admin_tokens',
|
||
{
|
||
id: text('id').primaryKey(),
|
||
userId: text('user_id')
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: 'cascade' }),
|
||
tokenHash: text('token_hash').notNull(),
|
||
label: text('label').notNull(),
|
||
scope: text('scope').notNull().default('admin'),
|
||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
index('admin_tokens_user_id_idx').on(t.userId),
|
||
uniqueIndex('admin_tokens_hash_idx').on(t.tokenHash),
|
||
],
|
||
);
|
||
|
||
// ─── Teams ───────────────────────────────────────────────────────────────────
|
||
// Declared before projects because projects references teams.
|
||
|
||
export const teams = pgTable('teams', {
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
name: text('name').notNull(),
|
||
slug: text('slug').notNull().unique(),
|
||
ownerId: text('owner_id')
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: 'restrict' }),
|
||
managerId: text('manager_id')
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: 'restrict' }),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
});
|
||
|
||
export const teamMembers = pgTable(
|
||
'team_members',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
teamId: uuid('team_id')
|
||
.notNull()
|
||
.references(() => teams.id, { onDelete: 'cascade' }),
|
||
userId: text('user_id')
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: 'cascade' }),
|
||
role: text('role', { enum: ['manager', 'member'] })
|
||
.notNull()
|
||
.default('member'),
|
||
invitedBy: text('invited_by').references(() => users.id, { onDelete: 'set null' }),
|
||
joinedAt: timestamp('joined_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => ({
|
||
uniq: uniqueIndex('team_members_team_user_idx').on(t.teamId, t.userId),
|
||
}),
|
||
);
|
||
|
||
// ─── Brain ───────────────────────────────────────────────────────────────────
|
||
// Declared before Chat because conversations references projects.
|
||
|
||
export const projects = pgTable('projects', {
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
name: text('name').notNull(),
|
||
description: text('description'),
|
||
status: text('status', { enum: ['active', 'paused', 'completed', 'archived'] })
|
||
.notNull()
|
||
.default('active'),
|
||
ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }),
|
||
teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }),
|
||
ownerType: text('owner_type', { enum: ['user', 'team'] })
|
||
.notNull()
|
||
.default('user'),
|
||
metadata: jsonb('metadata'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
});
|
||
|
||
export const missions = pgTable(
|
||
'missions',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
name: text('name').notNull(),
|
||
description: text('description'),
|
||
status: text('status', { enum: ['planning', 'active', 'paused', 'completed', 'failed'] })
|
||
.notNull()
|
||
.default('planning'),
|
||
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
|
||
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||
phase: text('phase'),
|
||
milestones: jsonb('milestones').$type<Record<string, unknown>[]>(),
|
||
config: jsonb('config'),
|
||
metadata: jsonb('metadata'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
index('missions_project_id_idx').on(t.projectId),
|
||
index('missions_user_id_idx').on(t.userId),
|
||
],
|
||
);
|
||
|
||
export const tasks = pgTable(
|
||
'tasks',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
title: text('title').notNull(),
|
||
description: text('description'),
|
||
status: text('status', {
|
||
enum: ['not-started', 'in-progress', 'blocked', 'done', 'cancelled'],
|
||
})
|
||
.notNull()
|
||
.default('not-started'),
|
||
priority: text('priority', { enum: ['critical', 'high', 'medium', 'low'] })
|
||
.notNull()
|
||
.default('medium'),
|
||
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
|
||
missionId: uuid('mission_id').references(() => missions.id, { onDelete: 'set null' }),
|
||
assignee: text('assignee'),
|
||
tags: jsonb('tags').$type<string[]>(),
|
||
dueDate: timestamp('due_date', { withTimezone: true }),
|
||
metadata: jsonb('metadata'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
index('tasks_project_id_idx').on(t.projectId),
|
||
index('tasks_mission_id_idx').on(t.missionId),
|
||
index('tasks_status_idx').on(t.status),
|
||
],
|
||
);
|
||
|
||
// ─── Coord Mission Tasks ─────────────────────────────────────────────────────
|
||
// Join table tracking coord-managed tasks within a mission.
|
||
// Scoped to userId for multi-tenant RBAC isolation.
|
||
|
||
export const missionTasks = pgTable(
|
||
'mission_tasks',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
missionId: uuid('mission_id')
|
||
.notNull()
|
||
.references(() => missions.id, { onDelete: 'cascade' }),
|
||
taskId: uuid('task_id').references(() => tasks.id, { onDelete: 'set null' }),
|
||
userId: text('user_id')
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: 'cascade' }),
|
||
status: text('status', {
|
||
enum: ['not-started', 'in-progress', 'blocked', 'done', 'cancelled'],
|
||
})
|
||
.notNull()
|
||
.default('not-started'),
|
||
description: text('description'),
|
||
notes: text('notes'),
|
||
pr: text('pr'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
index('mission_tasks_mission_id_idx').on(t.missionId),
|
||
index('mission_tasks_task_id_idx').on(t.taskId),
|
||
index('mission_tasks_user_id_idx').on(t.userId),
|
||
index('mission_tasks_status_idx').on(t.status),
|
||
],
|
||
);
|
||
|
||
export const events = pgTable(
|
||
'events',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
type: text('type').notNull(),
|
||
title: text('title').notNull(),
|
||
description: text('description'),
|
||
date: timestamp('date', { withTimezone: true }).notNull().defaultNow(),
|
||
metadata: jsonb('metadata'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [index('events_type_idx').on(t.type), index('events_date_idx').on(t.date)],
|
||
);
|
||
|
||
export const agents = pgTable(
|
||
'agents',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
name: text('name').notNull(),
|
||
provider: text('provider').notNull(),
|
||
model: text('model').notNull(),
|
||
status: text('status', { enum: ['idle', 'active', 'error', 'offline'] })
|
||
.notNull()
|
||
.default('idle'),
|
||
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
|
||
ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }),
|
||
systemPrompt: text('system_prompt'),
|
||
allowedTools: jsonb('allowed_tools').$type<string[]>(),
|
||
skills: jsonb('skills').$type<string[]>(),
|
||
isSystem: boolean('is_system').notNull().default(false),
|
||
config: jsonb('config'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
index('agents_project_id_idx').on(t.projectId),
|
||
index('agents_owner_id_idx').on(t.ownerId),
|
||
index('agents_is_system_idx').on(t.isSystem),
|
||
],
|
||
);
|
||
|
||
export const tickets = pgTable(
|
||
'tickets',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
title: text('title').notNull(),
|
||
description: text('description'),
|
||
status: text('status', { enum: ['open', 'in-progress', 'resolved', 'closed'] })
|
||
.notNull()
|
||
.default('open'),
|
||
priority: text('priority', { enum: ['critical', 'high', 'medium', 'low'] })
|
||
.notNull()
|
||
.default('medium'),
|
||
source: text('source'),
|
||
metadata: jsonb('metadata'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [index('tickets_status_idx').on(t.status)],
|
||
);
|
||
|
||
export const appreciations = pgTable('appreciations', {
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
fromUser: text('from_user'),
|
||
toUser: text('to_user'),
|
||
message: text('message').notNull(),
|
||
metadata: jsonb('metadata'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
});
|
||
|
||
// ─── Chat ────────────────────────────────────────────────────────────────────
|
||
|
||
export const conversations = pgTable(
|
||
'conversations',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
title: text('title'),
|
||
userId: text('user_id')
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: 'cascade' }),
|
||
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
|
||
agentId: uuid('agent_id').references(() => agents.id, { onDelete: 'set null' }),
|
||
/** M5-004: Agent session ID bound to this conversation. Nullable — set when a session is created. */
|
||
sessionId: text('session_id'),
|
||
archived: boolean('archived').notNull().default(false),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
// Compound index for the most common query: conversations for a user filtered by archived.
|
||
index('conversations_user_archived_idx').on(t.userId, t.archived),
|
||
index('conversations_project_id_idx').on(t.projectId),
|
||
index('conversations_agent_id_idx').on(t.agentId),
|
||
],
|
||
);
|
||
|
||
export const messages = pgTable(
|
||
'messages',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
conversationId: uuid('conversation_id')
|
||
.notNull()
|
||
.references(() => conversations.id, { onDelete: 'cascade' }),
|
||
role: text('role', { enum: ['user', 'assistant', 'system'] }).notNull(),
|
||
content: text('content').notNull(),
|
||
metadata: jsonb('metadata'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [index('messages_conversation_id_idx').on(t.conversationId)],
|
||
);
|
||
|
||
// ─── pgvector custom type ───────────────────────────────────────────────────
|
||
|
||
export const vector = customType<{
|
||
data: number[];
|
||
driverParam: string;
|
||
config: { dimensions: number };
|
||
}>({
|
||
dataType(config) {
|
||
return `vector(${config?.dimensions ?? 1536})`;
|
||
},
|
||
fromDriver(value: unknown): number[] {
|
||
const str = value as string;
|
||
return str
|
||
.slice(1, -1)
|
||
.split(',')
|
||
.map((v) => Number(v));
|
||
},
|
||
toDriver(value: number[]): string {
|
||
return `[${value.join(',')}]`;
|
||
},
|
||
});
|
||
|
||
// ─── Memory ─────────────────────────────────────────────────────────────────
|
||
|
||
export const preferences = pgTable(
|
||
'preferences',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
userId: text('user_id')
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: 'cascade' }),
|
||
key: text('key').notNull(),
|
||
value: jsonb('value').notNull(),
|
||
category: text('category', {
|
||
enum: ['communication', 'coding', 'workflow', 'appearance', 'general'],
|
||
})
|
||
.notNull()
|
||
.default('general'),
|
||
source: text('source'),
|
||
mutable: boolean('mutable').notNull().default(true),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
index('preferences_user_id_idx').on(t.userId),
|
||
// Unique constraint enables single-round-trip INSERT … ON CONFLICT DO UPDATE.
|
||
uniqueIndex('preferences_user_key_idx').on(t.userId, t.key),
|
||
],
|
||
);
|
||
|
||
export const insights = pgTable(
|
||
'insights',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
userId: text('user_id')
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: 'cascade' }),
|
||
content: text('content').notNull(),
|
||
embedding: vector('embedding', { dimensions: 1536 }),
|
||
source: text('source', {
|
||
enum: ['agent', 'user', 'summarization', 'system'],
|
||
})
|
||
.notNull()
|
||
.default('agent'),
|
||
category: text('category', {
|
||
enum: ['decision', 'learning', 'preference', 'fact', 'pattern', 'general'],
|
||
})
|
||
.notNull()
|
||
.default('general'),
|
||
relevanceScore: real('relevance_score').notNull().default(1.0),
|
||
metadata: jsonb('metadata'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
decayedAt: timestamp('decayed_at', { withTimezone: true }),
|
||
},
|
||
(t) => [
|
||
index('insights_user_id_idx').on(t.userId),
|
||
index('insights_category_idx').on(t.category),
|
||
index('insights_relevance_idx').on(t.relevanceScore),
|
||
],
|
||
);
|
||
|
||
// ─── Agent Logs ─────────────────────────────────────────────────────────────
|
||
|
||
export const agentLogs = pgTable(
|
||
'agent_logs',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
sessionId: text('session_id').notNull(),
|
||
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||
level: text('level', { enum: ['debug', 'info', 'warn', 'error'] })
|
||
.notNull()
|
||
.default('info'),
|
||
category: text('category', {
|
||
enum: ['decision', 'tool_use', 'learning', 'error', 'general'],
|
||
})
|
||
.notNull()
|
||
.default('general'),
|
||
content: text('content').notNull(),
|
||
metadata: jsonb('metadata'),
|
||
tier: text('tier', { enum: ['hot', 'warm', 'cold'] })
|
||
.notNull()
|
||
.default('hot'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
summarizedAt: timestamp('summarized_at', { withTimezone: true }),
|
||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||
},
|
||
(t) => [
|
||
// Compound index for session log queries (most common: session + tier filter).
|
||
index('agent_logs_session_tier_idx').on(t.sessionId, t.tier),
|
||
index('agent_logs_user_id_idx').on(t.userId),
|
||
// Used by summarization cron to find hot logs older than a cutoff.
|
||
index('agent_logs_tier_created_at_idx').on(t.tier, t.createdAt),
|
||
],
|
||
);
|
||
|
||
// ─── Logical agent connector authority ──────────────────────────────────────
|
||
// One durable row is the current authority for a tenant/logical-agent/binding.
|
||
// Runtime-native session identifiers never enter these core tables.
|
||
|
||
export const logicalAgentConnectorLeases = pgTable(
|
||
'logical_agent_connector_leases',
|
||
{
|
||
leaseId: uuid('lease_id').primaryKey(),
|
||
tenantId: text('tenant_id').notNull(),
|
||
logicalAgentId: text('logical_agent_id').notNull(),
|
||
bindingId: text('binding_id').notNull(),
|
||
connectorId: text('connector_id').notNull(),
|
||
scopes: jsonb('scopes').notNull().$type<string[]>(),
|
||
leaseEpoch: bigint('lease_epoch', { mode: 'bigint' }).notNull(),
|
||
acquiredAt: timestamp('acquired_at', { withTimezone: true }).notNull(),
|
||
heartbeatAt: timestamp('heartbeat_at', { withTimezone: true }).notNull(),
|
||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||
releasedAt: timestamp('released_at', { withTimezone: true }),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
uniqueIndex('logical_agent_connector_lease_binding_idx').on(
|
||
t.tenantId,
|
||
t.logicalAgentId,
|
||
t.bindingId,
|
||
),
|
||
index('logical_agent_connector_lease_expiry_idx').on(t.expiresAt),
|
||
index('logical_agent_connector_lease_connector_idx').on(t.connectorId),
|
||
],
|
||
);
|
||
|
||
/** Append-only, credential-safe lease lifecycle and fencing denial metadata. */
|
||
export const connectorLeaseAuditLog = pgTable(
|
||
'connector_lease_audit_log',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
tenantId: text('tenant_id').notNull(),
|
||
logicalAgentId: text('logical_agent_id').notNull(),
|
||
bindingId: text('binding_id').notNull(),
|
||
connectorId: text('connector_id').notNull(),
|
||
leaseId: uuid('lease_id'),
|
||
leaseEpoch: bigint('lease_epoch', { mode: 'bigint' }),
|
||
event: text('event', {
|
||
enum: ['acquire', 'renew', 'takeover', 'reject', 'release', 'expiry'],
|
||
}).notNull(),
|
||
outcome: text('outcome', { enum: ['succeeded', 'denied'] }).notNull(),
|
||
reason: text('reason'),
|
||
correlationId: text('correlation_id').notNull(),
|
||
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
|
||
},
|
||
(t) => [
|
||
index('connector_lease_audit_binding_occurred_idx').on(
|
||
t.tenantId,
|
||
t.logicalAgentId,
|
||
t.bindingId,
|
||
t.occurredAt.desc(),
|
||
),
|
||
index('connector_lease_audit_correlation_idx').on(t.correlationId),
|
||
],
|
||
);
|
||
|
||
// ─── Tess durable session state ─────────────────────────────────────────────
|
||
// PostgreSQL is canonical for restart-safe Tess session recovery. The state
|
||
// machine lives in @mosaicstack/agent; these records are its durable adapter.
|
||
|
||
export const interactionInboxStatusEnum = pgEnum('interaction_inbox_status', [
|
||
'pending',
|
||
'processing',
|
||
'processed',
|
||
]);
|
||
export const interactionOutboxStatusEnum = pgEnum('interaction_outbox_status', [
|
||
'pending',
|
||
'processing',
|
||
'delivered',
|
||
]);
|
||
export const interactionHandoffStatusEnum = pgEnum('interaction_handoff_status', [
|
||
'pending',
|
||
'accepted',
|
||
]);
|
||
|
||
export const interactionSessions = pgTable('interaction_sessions', {
|
||
id: text('id').primaryKey(),
|
||
agentName: text('agent_name').notNull(),
|
||
tenantId: text('tenant_id').notNull(),
|
||
ownerId: text('owner_id')
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: 'cascade' }),
|
||
providerId: text('provider_id').notNull(),
|
||
runtimeSessionId: text('runtime_session_id').notNull(),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
});
|
||
|
||
export const interactionInbox = pgTable(
|
||
'interaction_inbox',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
sessionId: text('session_id')
|
||
.notNull()
|
||
.references(() => interactionSessions.id, { onDelete: 'cascade' }),
|
||
idempotencyKey: text('idempotency_key').notNull(),
|
||
correlationId: text('correlation_id').notNull(),
|
||
content: text('content').notNull(),
|
||
contentDigest: text('content_digest').notNull(),
|
||
status: interactionInboxStatusEnum('status').notNull().default('pending'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
uniqueIndex('interaction_inbox_session_idempotency_idx').on(t.sessionId, t.idempotencyKey),
|
||
index('interaction_inbox_session_status_created_idx').on(t.sessionId, t.status, t.createdAt),
|
||
],
|
||
);
|
||
|
||
export const interactionOutbox = pgTable(
|
||
'interaction_outbox',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
sessionId: text('session_id')
|
||
.notNull()
|
||
.references(() => interactionSessions.id, { onDelete: 'cascade' }),
|
||
idempotencyKey: text('idempotency_key').notNull(),
|
||
correlationId: text('correlation_id').notNull(),
|
||
channelId: text('channel_id').notNull(),
|
||
kind: text('kind').notNull(),
|
||
content: text('content').notNull(),
|
||
contentDigest: text('content_digest').notNull(),
|
||
status: interactionOutboxStatusEnum('status').notNull().default('pending'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
uniqueIndex('interaction_outbox_session_idempotency_idx').on(t.sessionId, t.idempotencyKey),
|
||
index('interaction_outbox_session_status_created_idx').on(t.sessionId, t.status, t.createdAt),
|
||
],
|
||
);
|
||
|
||
export const interactionCheckpoints = pgTable(
|
||
'interaction_checkpoints',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
sessionId: text('session_id')
|
||
.notNull()
|
||
.references(() => interactionSessions.id, { onDelete: 'cascade' }),
|
||
checkpointId: text('checkpoint_id').notNull(),
|
||
contentDigest: text('content_digest').notNull(),
|
||
cursor: text('cursor').notNull(),
|
||
summary: text('summary').notNull(),
|
||
compactionEpoch: integer('compaction_epoch').notNull(),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
uniqueIndex('interaction_checkpoints_session_idempotency_idx').on(t.sessionId, t.checkpointId),
|
||
index('interaction_checkpoints_session_epoch_idx').on(t.sessionId, t.compactionEpoch),
|
||
],
|
||
);
|
||
|
||
export const interactionHandoffs = pgTable(
|
||
'interaction_handoffs',
|
||
{
|
||
handoffId: text('handoff_id').primaryKey(),
|
||
sessionId: text('session_id')
|
||
.notNull()
|
||
.references(() => interactionSessions.id, { onDelete: 'cascade' }),
|
||
destination: text('destination').notNull(),
|
||
correlationId: text('correlation_id').notNull(),
|
||
checkpointId: text('checkpoint_id').notNull(),
|
||
status: interactionHandoffStatusEnum('status').notNull().default('pending'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [index('interaction_handoffs_session_status_idx').on(t.sessionId, t.status)],
|
||
);
|
||
|
||
// ─── Skills ─────────────────────────────────────────────────────────────────
|
||
|
||
export const skills = pgTable(
|
||
'skills',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
name: text('name').notNull().unique(),
|
||
description: text('description'),
|
||
version: text('version'),
|
||
source: text('source', { enum: ['builtin', 'community', 'custom'] })
|
||
.notNull()
|
||
.default('custom'),
|
||
config: jsonb('config'),
|
||
enabled: boolean('enabled').notNull().default(true),
|
||
installedBy: text('installed_by').references(() => users.id, { onDelete: 'set null' }),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [index('skills_enabled_idx').on(t.enabled)],
|
||
);
|
||
|
||
// ─── Routing Rules ──────────────────────────────────────────────────────────
|
||
|
||
export const routingRules = pgTable(
|
||
'routing_rules',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
/** Human-readable rule name */
|
||
name: text('name').notNull(),
|
||
/** Lower number = higher priority; unique per scope */
|
||
priority: integer('priority').notNull(),
|
||
/** 'system' rules apply globally; 'user' rules are scoped to a specific user */
|
||
scope: text('scope', { enum: ['system', 'user'] })
|
||
.notNull()
|
||
.default('system'),
|
||
/** Null for system-scoped rules; FK to users.id for user-scoped rules */
|
||
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||
/** Array of condition objects that must all match for the rule to fire */
|
||
conditions: jsonb('conditions').notNull().$type<Record<string, unknown>[]>(),
|
||
/** Routing action to take when all conditions are satisfied */
|
||
action: jsonb('action').notNull().$type<Record<string, unknown>>(),
|
||
/** Whether this rule is active */
|
||
enabled: boolean('enabled').notNull().default(true),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
// Lookup by scope + priority for ordered rule evaluation
|
||
index('routing_rules_scope_priority_idx').on(t.scope, t.priority),
|
||
// User-scoped rules lookup
|
||
index('routing_rules_user_id_idx').on(t.userId),
|
||
// Filter enabled rules efficiently
|
||
index('routing_rules_enabled_idx').on(t.enabled),
|
||
],
|
||
);
|
||
|
||
// ─── Provider Credentials ────────────────────────────────────────────────────
|
||
|
||
export const providerCredentials = pgTable(
|
||
'provider_credentials',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
userId: text('user_id')
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: 'cascade' }),
|
||
provider: text('provider').notNull(),
|
||
credentialType: text('credential_type', { enum: ['api_key', 'oauth_token'] }).notNull(),
|
||
encryptedValue: text('encrypted_value').notNull(),
|
||
refreshToken: text('refresh_token'),
|
||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||
metadata: jsonb('metadata'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
// Unique constraint: one credential entry per user per provider
|
||
uniqueIndex('provider_credentials_user_provider_idx').on(t.userId, t.provider),
|
||
index('provider_credentials_user_id_idx').on(t.userId),
|
||
],
|
||
);
|
||
|
||
// ─── Summarization Jobs ─────────────────────────────────────────────────────
|
||
|
||
export const summarizationJobs = pgTable(
|
||
'summarization_jobs',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
status: text('status', { enum: ['pending', 'running', 'completed', 'failed'] })
|
||
.notNull()
|
||
.default('pending'),
|
||
logsProcessed: integer('logs_processed').notNull().default(0),
|
||
insightsCreated: integer('insights_created').notNull().default(0),
|
||
errorMessage: text('error_message'),
|
||
startedAt: timestamp('started_at', { withTimezone: true }),
|
||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [index('summarization_jobs_status_idx').on(t.status)],
|
||
);
|
||
|
||
// ─── Fleet Backlog ────────────────────────────────────────────────────────────
|
||
// Mosaic-native backlog-of-record (card A4). This REPLACES the former Hermes
|
||
// adapter — there is NO runtime dependency on Hermes. Cards form a dependency
|
||
// DAG (`depends_on`), are claimed atomically by fleet workers via
|
||
// `SELECT ... FOR UPDATE SKIP LOCKED`, and auto-expire via a TTL so a crashed
|
||
// claimer's card returns to the pool.
|
||
|
||
/**
|
||
* Lifecycle status of a backlog card.
|
||
* - ready: eligible to be claimed (once its deps are all `done`).
|
||
* - claimed: a worker holds it (claim_owner + claimed_at set); may expire via TTL.
|
||
* - blocked: explicitly parked; never auto-claimed.
|
||
* - done: completed; satisfies dependents.
|
||
*/
|
||
export const backlogStatusEnum = pgEnum('backlog_status', ['ready', 'claimed', 'blocked', 'done']);
|
||
|
||
export const backlog = pgTable(
|
||
'backlog',
|
||
{
|
||
/** Stable, caller-supplied card id (e.g. "A4", "fleet-001"). PK. */
|
||
id: text('id').primaryKey(),
|
||
title: text('title').notNull(),
|
||
body: text('body'),
|
||
/** Board/phase grouping (e.g. "M1", "fleet"). Free-form. */
|
||
phase: text('phase'),
|
||
/** Higher number = higher priority; claim picks the max-priority ready card. */
|
||
priority: integer('priority').notNull().default(0),
|
||
status: backlogStatusEnum('status').notNull().default('ready'),
|
||
/** DAG edges: ids of cards this one depends on. "ready" requires all done. */
|
||
dependsOn: jsonb('depends_on').notNull().$type<string[]>().default([]),
|
||
/** Owner token of the current claim (worker/agent id). NULL when unclaimed. */
|
||
claimOwner: text('claim_owner'),
|
||
/** TTL of the active claim in seconds. NULL when unclaimed. */
|
||
claimTtlSeconds: integer('claim_ttl_seconds'),
|
||
/** When the active claim was taken. NULL when unclaimed. claimed_at + ttl = expiry. */
|
||
claimedAt: timestamp('claimed_at', { withTimezone: true }),
|
||
/** Count of times this card has been claimed (incremented on each claim). */
|
||
attempts: integer('attempts').notNull().default(0),
|
||
/** Optional dedup key for `create`; a repeat key returns the existing card. */
|
||
idempotencyKey: text('idempotency_key'),
|
||
/** Acceptance criteria — free-form JSON (array of strings or object). */
|
||
acceptance: jsonb('acceptance'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
// Hot path: claim scans ready cards ordered by priority then age.
|
||
index('backlog_status_priority_idx').on(t.status, t.priority),
|
||
// reclaim sweeps claimed cards by claimed_at to find expired ones.
|
||
index('backlog_status_claimed_at_idx').on(t.status, t.claimedAt),
|
||
// Idempotent create dedups on this key (NULLs are distinct in Postgres, so
|
||
// many unkeyed cards coexist; a repeated non-null key collides).
|
||
uniqueIndex('backlog_idempotency_key_idx').on(t.idempotencyKey),
|
||
],
|
||
);
|
||
|
||
// ─── Federation ──────────────────────────────────────────────────────────────
|
||
// Enums declared before tables that reference them.
|
||
// All federation definitions live in this file (avoids CJS/ESM cross-import
|
||
// issues when drizzle-kit loads schema files via esbuild-register).
|
||
// Application code imports from `federation.ts` which re-exports from here.
|
||
|
||
/**
|
||
* Lifecycle state of a federation peer.
|
||
* - pending: registered but not yet approved / TLS handshake not confirmed
|
||
* - active: fully operational; mTLS verified
|
||
* - suspended: temporarily blocked; cert still valid
|
||
* - revoked: cert revoked; no traffic allowed
|
||
*/
|
||
export const peerStateEnum = pgEnum('peer_state', ['pending', 'active', 'suspended', 'revoked']);
|
||
|
||
/**
|
||
* Lifecycle state of a federation grant.
|
||
* - pending: created but not yet activated (awaiting cert enrollment, M2-07)
|
||
* - active: grant is in effect
|
||
* - revoked: manually revoked before expiry
|
||
* - expired: natural expiry (expires_at passed)
|
||
*/
|
||
export const grantStatusEnum = pgEnum('grant_status', ['pending', 'active', 'revoked', 'expired']);
|
||
|
||
/**
|
||
* A registered peer gateway identified by its Step-CA certificate CN.
|
||
* Represents both inbound peers (other gateways querying us) and outbound
|
||
* peers (gateways we query — identified by client_key_pem being set).
|
||
*/
|
||
export const federationPeers = pgTable(
|
||
'federation_peers',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
|
||
/** Certificate CN, e.g. "gateway-uscllc-com". Unique — one row per peer identity. */
|
||
commonName: text('common_name').notNull().unique(),
|
||
|
||
/** Human-friendly label shown in admin UI. */
|
||
displayName: text('display_name').notNull(),
|
||
|
||
/** Pinned PEM certificate used for mTLS verification. */
|
||
certPem: text('cert_pem').notNull(),
|
||
|
||
/** Certificate serial number — used for CRL / revocation lookup. */
|
||
certSerial: text('cert_serial').notNull().unique(),
|
||
|
||
/** Certificate expiry — used by the renewal scheduler (FED-M6). */
|
||
certNotAfter: timestamp('cert_not_after', { withTimezone: true }).notNull(),
|
||
|
||
/**
|
||
* Sealed (encrypted) private key for outbound connections TO this peer.
|
||
* NULL for inbound-only peer rows (we serve them; we don't call them).
|
||
*/
|
||
clientKeyPem: text('client_key_pem'),
|
||
|
||
/** Current peer lifecycle state. */
|
||
state: peerStateEnum('state').notNull().default('pending'),
|
||
|
||
/** Base URL for outbound queries, e.g. "https://woltje.com:443". NULL for inbound-only peers. */
|
||
endpointUrl: text('endpoint_url'),
|
||
|
||
/** Timestamp of the most recent successful inbound or outbound request. */
|
||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
|
||
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
|
||
/** Populated when the cert is revoked; NULL while the peer is active. */
|
||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||
},
|
||
(t) => [
|
||
// CRL / revocation lookups by serial.
|
||
index('federation_peers_cert_serial_idx').on(t.certSerial),
|
||
// Filter peers by state (e.g. find all active peers for outbound routing).
|
||
index('federation_peers_state_idx').on(t.state),
|
||
],
|
||
);
|
||
|
||
/**
|
||
* A grant lets a specific peer cert query a specific local user's data within
|
||
* a defined scope. Scopes are validated by JSON Schema in M2-03; this table
|
||
* stores them as raw jsonb.
|
||
*/
|
||
export const federationGrants = pgTable(
|
||
'federation_grants',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
|
||
/**
|
||
* The local user whose data this grant exposes.
|
||
* Cascade delete: if the user account is deleted, revoke all their grants.
|
||
*/
|
||
subjectUserId: text('subject_user_id')
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: 'cascade' }),
|
||
|
||
/**
|
||
* The peer gateway holding the grant.
|
||
* Cascade delete: if the peer record is removed, the grant is moot.
|
||
*/
|
||
peerId: uuid('peer_id')
|
||
.notNull()
|
||
.references(() => federationPeers.id, { onDelete: 'cascade' }),
|
||
|
||
/**
|
||
* Scope object — validated by JSON Schema (M2-03).
|
||
* Example: { "resources": ["tasks", "notes"], "operations": ["list", "get"] }
|
||
*/
|
||
scope: jsonb('scope').notNull(),
|
||
|
||
/** Current grant lifecycle state. */
|
||
status: grantStatusEnum('status').notNull().default('pending'),
|
||
|
||
/** Optional hard expiry. NULL means the grant does not expire automatically. */
|
||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
|
||
/** Populated when the grant is explicitly revoked. */
|
||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||
|
||
/** Human-readable reason for revocation (audit trail). */
|
||
revokedReason: text('revoked_reason'),
|
||
},
|
||
(t) => [
|
||
// Hot path: look up active grants for a subject user (auth middleware).
|
||
index('federation_grants_subject_status_idx').on(t.subjectUserId, t.status),
|
||
// Hot path: look up active grants held by a peer (inbound request check).
|
||
index('federation_grants_peer_status_idx').on(t.peerId, t.status),
|
||
],
|
||
);
|
||
|
||
/**
|
||
* Append-only audit log of all federation requests.
|
||
* M4 writes rows here. M2 only creates the table.
|
||
*
|
||
* All FKs use SET NULL so audit rows survive peer/user/grant deletion.
|
||
*/
|
||
export const federationAuditLog = pgTable(
|
||
'federation_audit_log',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
|
||
/** UUIDv7 from the X-Request-ID header — correlates with OTEL traces. */
|
||
requestId: text('request_id').notNull(),
|
||
|
||
/** Peer that made the request. SET NULL if the peer is later deleted. */
|
||
peerId: uuid('peer_id').references(() => federationPeers.id, { onDelete: 'set null' }),
|
||
|
||
/** Subject user whose data was queried. SET NULL if the user is deleted. */
|
||
subjectUserId: text('subject_user_id').references(() => users.id, { onDelete: 'set null' }),
|
||
|
||
/** Grant under which the request was authorised. SET NULL if the grant is deleted. */
|
||
grantId: uuid('grant_id').references(() => federationGrants.id, { onDelete: 'set null' }),
|
||
|
||
/** Request verb: "list" | "get" | "search". */
|
||
verb: text('verb').notNull(),
|
||
|
||
/** Resource type: "tasks" | "notes" | "memory" | etc. */
|
||
resource: text('resource').notNull(),
|
||
|
||
/** HTTP status code returned to the peer. */
|
||
statusCode: integer('status_code').notNull(),
|
||
|
||
/** Number of items returned (NULL for non-list requests or errors). */
|
||
resultCount: integer('result_count'),
|
||
|
||
/** Why the request was denied (NULL when allowed). */
|
||
deniedReason: text('denied_reason'),
|
||
|
||
/** End-to-end latency in milliseconds. */
|
||
latencyMs: integer('latency_ms'),
|
||
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
|
||
// Reserved for M4 — see PRD 7.3
|
||
/** SHA-256 of the normalised GraphQL/REST query string; written by M4 search. */
|
||
queryHash: text('query_hash'),
|
||
/** Request outcome: "allowed" | "denied" | "partial"; written by M4. */
|
||
outcome: text('outcome'),
|
||
/** Response payload size in bytes; written by M4. */
|
||
bytesOut: integer('bytes_out'),
|
||
},
|
||
(t) => [
|
||
// Per-peer request history in reverse chronological order.
|
||
index('federation_audit_log_peer_created_at_idx').on(t.peerId, t.createdAt.desc()),
|
||
// Per-user access log in reverse chronological order.
|
||
index('federation_audit_log_subject_created_at_idx').on(t.subjectUserId, t.createdAt.desc()),
|
||
// Global time-range scans (dashboards, rate-limit windows).
|
||
index('federation_audit_log_created_at_idx').on(t.createdAt.desc()),
|
||
],
|
||
);
|
||
|
||
/**
|
||
* Single-use enrollment tokens — M2-07.
|
||
*
|
||
* An admin creates a token (with a TTL) and hands it out-of-band to the
|
||
* remote peer operator. The peer redeems it exactly once by posting its
|
||
* CSR to POST /api/federation/enrollment/:token. The token is atomically
|
||
* marked as used to prevent replay attacks.
|
||
*/
|
||
export const federationEnrollmentTokens = pgTable('federation_enrollment_tokens', {
|
||
/** 32-byte hex token — crypto.randomBytes(32).toString('hex') */
|
||
token: text('token').primaryKey(),
|
||
|
||
/** The federation grant this enrollment activates. */
|
||
grantId: uuid('grant_id')
|
||
.notNull()
|
||
.references(() => federationGrants.id, { onDelete: 'cascade' }),
|
||
|
||
/** The peer record that will be updated on successful enrollment. */
|
||
peerId: uuid('peer_id')
|
||
.notNull()
|
||
.references(() => federationPeers.id, { onDelete: 'cascade' }),
|
||
|
||
/** Hard expiry — token rejected after this time even if not used. */
|
||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||
|
||
/** NULL until the token is redeemed. Set atomically to prevent replay. */
|
||
usedAt: timestamp('used_at', { withTimezone: true }),
|
||
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
});
|
||
|
||
// ─── Hierarchy (tenancy/authorization structure record class) ────────────────
|
||
// Contract: docs/requirements/hierarchy-schema.md (D2, ratified 2026-08-27).
|
||
// Five tables: companies → estates → platform_projects → workspaces, plus
|
||
// hierarchy_grants. Class rows carry parentage, naming, grant, and
|
||
// audit-linkage data only — the column sets below are exhaustive (§2.7) and
|
||
// witnessed against information_schema (§6.2). No owner_id: ownership is the
|
||
// grant structure (§4.4). All writes flow through the Gateway hierarchy
|
||
// command family only (§5.1), enforced by the writer-coverage assertion
|
||
// (§6.3b) — do not add writers outside that allowlist.
|
||
|
||
/** Company visibility classes (contract 1 §2.8, Ruling 4b): 'private' is the
|
||
* only creatable class (§5.5 — creation carries no visibility argument);
|
||
* 'directory' discloses existence/name/slug to all users and is entered only
|
||
* through the admin-gated visibility-change command. */
|
||
export const COMPANY_VISIBILITY = ['private', 'directory'] as const;
|
||
|
||
export const companies = pgTable(
|
||
'companies',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
name: text('name').notNull(),
|
||
slug: text('slug').notNull().unique(),
|
||
visibility: text('visibility').notNull().default('private'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
() => [check('companies_visibility_check', sql`visibility IN ('private', 'directory')`)],
|
||
);
|
||
|
||
export const estates = pgTable(
|
||
'estates',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
name: text('name').notNull(),
|
||
slug: text('slug').notNull(),
|
||
companyId: uuid('company_id')
|
||
.notNull()
|
||
.references(() => companies.id, { onDelete: 'restrict' }),
|
||
},
|
||
(t) => [unique('estates_company_slug_uniq').on(t.companyId, t.slug)],
|
||
);
|
||
|
||
export const platformProjects = pgTable(
|
||
'platform_projects',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
name: text('name').notNull(),
|
||
slug: text('slug').notNull(),
|
||
estateId: uuid('estate_id')
|
||
.notNull()
|
||
.references(() => estates.id, { onDelete: 'restrict' }),
|
||
},
|
||
(t) => [unique('platform_projects_estate_slug_uniq').on(t.estateId, t.slug)],
|
||
);
|
||
|
||
export const workspaces = pgTable(
|
||
'workspaces',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
name: text('name').notNull(),
|
||
slug: text('slug').notNull(),
|
||
platformProjectId: uuid('platform_project_id')
|
||
.notNull()
|
||
.references(() => platformProjects.id, { onDelete: 'restrict' }),
|
||
},
|
||
(t) => [unique('workspaces_platform_project_slug_uniq').on(t.platformProjectId, t.slug)],
|
||
);
|
||
|
||
/** Grant role vocabulary (contract 2 §2): totally ordered, viewer ⊂ member ⊂
|
||
* owner. Order in this tuple IS the ordering — index = strength. */
|
||
export const HIERARCHY_GRANT_ROLES = ['viewer', 'member', 'owner'] as const;
|
||
|
||
export const hierarchyGrants = pgTable(
|
||
'hierarchy_grants',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
// Subject: exactly one of user/team (CHECK below). Principal FKs are
|
||
// RESTRICT until a deletion-and-retention contract rules otherwise (§3.3).
|
||
userId: text('user_id').references(() => users.id, { onDelete: 'restrict' }),
|
||
teamId: uuid('team_id').references(() => teams.id, { onDelete: 'restrict' }),
|
||
// Target: exactly one of the three grantable levels (CHECK below).
|
||
// Target FKs CASCADE — the one permitted cascade in the class (§3.3);
|
||
// cascaded grant deletions are audited by the command family (§5.2).
|
||
companyId: uuid('company_id').references(() => companies.id, { onDelete: 'cascade' }),
|
||
estateId: uuid('estate_id').references(() => estates.id, { onDelete: 'cascade' }),
|
||
platformProjectId: uuid('platform_project_id').references(() => platformProjects.id, {
|
||
onDelete: 'cascade',
|
||
}),
|
||
// Role vocabulary per contract 2 §2: exactly viewer ⊂ member ⊂ owner,
|
||
// totally ordered; CHECK below closes the column to that vocabulary.
|
||
role: text('role').notNull(),
|
||
grantedBy: text('granted_by')
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: 'restrict' }),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
check('hierarchy_grants_role_check', sql`role IN ('viewer', 'member', 'owner')`),
|
||
check('hierarchy_grants_subject_check', sql`num_nonnulls(user_id, team_id) = 1`),
|
||
check(
|
||
'hierarchy_grants_target_check',
|
||
sql`num_nonnulls(company_id, estate_id, platform_project_id) = 1`,
|
||
),
|
||
// At most one grant per (subject, target, role) across all six
|
||
// subject×target forms — NULLS NOT DISTINCT so nullable columns
|
||
// participate (§3.2).
|
||
unique('hierarchy_grants_subject_target_role_uniq')
|
||
.on(t.userId, t.teamId, t.companyId, t.estateId, t.platformProjectId, t.role)
|
||
.nullsNotDistinct(),
|
||
index('hierarchy_grants_company_id_idx').on(t.companyId),
|
||
index('hierarchy_grants_estate_id_idx').on(t.estateId),
|
||
index('hierarchy_grants_platform_project_id_idx').on(t.platformProjectId),
|
||
index('hierarchy_grants_user_id_idx').on(t.userId),
|
||
index('hierarchy_grants_team_id_idx').on(t.teamId),
|
||
index('hierarchy_grants_granted_by_idx').on(t.grantedBy),
|
||
],
|
||
);
|
||
|
||
// ─── Hierarchy audit events + outbox (contract 1 §5.2) ──────────────────────
|
||
// NOT part of the record class (the class is exactly the five tables above).
|
||
// Append-only semantic audit log for hierarchy mutations, with a dedicated
|
||
// transactional outbox — hierarchy events are not workspace-scoped rows and
|
||
// do not ride the workspace outbox. Deletion-safe linkage: events reference
|
||
// their target by an immutable snapshot (id, slug, parent chain at event
|
||
// time), never by a foreign key into the class tables, so append-only events
|
||
// survive the deletion of their target. Append-only is enforced at the
|
||
// application layer (the hierarchy audit repository exposes no update/delete
|
||
// path for events); REQ-AUD-001's INSERT/SELECT-only database role is a
|
||
// deployment concern outside this schema.
|
||
|
||
export const HIERARCHY_AUDIT_VERBS = [
|
||
'create',
|
||
'rename',
|
||
'transfer',
|
||
'visibility_change',
|
||
'delete',
|
||
'grant_create',
|
||
'grant_change',
|
||
'grant_revoke',
|
||
] as const;
|
||
|
||
export const HIERARCHY_AUDIT_TARGET_KINDS = [
|
||
'company',
|
||
'estate',
|
||
'platform_project',
|
||
'grant',
|
||
] as const;
|
||
|
||
export const hierarchyAuditEvents = pgTable(
|
||
'hierarchy_audit_events',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
// Global append order; per-target ordering (REQ-AUD-001) is a filter on
|
||
// target_id ordered by seq.
|
||
seq: bigint('seq', { mode: 'number' }).notNull().generatedAlwaysAsIdentity(),
|
||
// No FK: audit events outlive every principal and every target (§5.2).
|
||
actorId: text('actor_id').notNull(),
|
||
verb: text('verb').notNull(),
|
||
targetKind: text('target_kind').notNull(),
|
||
targetId: uuid('target_id').notNull(),
|
||
// Immutable snapshot at event time. Node events: { id, slug, name,
|
||
// parentChain: [{ kind, id, slug }, …] root-first }. Grant events:
|
||
// { id, subject: { userId | teamId }, target: { kind, id }, role }
|
||
// (subject and role per contract 2 §4.4).
|
||
targetSnapshot: jsonb('target_snapshot').notNull(),
|
||
// Present exactly on transfers: snapshot of the source/destination
|
||
// parent ({ kind, id, slug }), CHECK-enforced below.
|
||
transferFrom: jsonb('transfer_from'),
|
||
transferTo: jsonb('transfer_to'),
|
||
correlationId: text('correlation_id').notNull(),
|
||
// Prior event in the causal chain (e.g. cascaded grant_revoke events
|
||
// caused by a node delete). Self-FK RESTRICT keeps the chain intact.
|
||
causationId: uuid('causation_id').references((): AnyPgColumn => hierarchyAuditEvents.id, {
|
||
onDelete: 'restrict',
|
||
}),
|
||
idempotencyKey: text('idempotency_key').notNull(),
|
||
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(t) => [
|
||
uniqueIndex('hierarchy_audit_events_idempotency_idx').on(t.idempotencyKey),
|
||
uniqueIndex('hierarchy_audit_events_seq_idx').on(t.seq),
|
||
index('hierarchy_audit_events_target_seq_idx').on(t.targetId, t.seq),
|
||
index('hierarchy_audit_events_correlation_idx').on(t.correlationId),
|
||
check(
|
||
'hierarchy_audit_events_verb_check',
|
||
sql`verb IN ('create', 'rename', 'transfer', 'visibility_change', 'delete', 'grant_create', 'grant_change', 'grant_revoke')`,
|
||
),
|
||
check(
|
||
'hierarchy_audit_events_target_kind_check',
|
||
sql`target_kind IN ('company', 'estate', 'platform_project', 'grant')`,
|
||
),
|
||
check(
|
||
'hierarchy_audit_events_transfer_check',
|
||
sql`(verb = 'transfer') = (transfer_from IS NOT NULL AND transfer_to IS NOT NULL)`,
|
||
),
|
||
],
|
||
);
|
||
|
||
export const hierarchyOutboxStatusEnum = pgEnum('hierarchy_outbox_status', [
|
||
'pending',
|
||
'processing',
|
||
'delivered',
|
||
]);
|
||
|
||
export const hierarchyOutbox = pgTable(
|
||
'hierarchy_outbox',
|
||
{
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
// FK into the append-only events table (not a class table): never
|
||
// dangles, so RESTRICT is safe and keeps event/outbox integrity.
|
||
eventId: uuid('event_id')
|
||
.notNull()
|
||
.references(() => hierarchyAuditEvents.id, { onDelete: 'restrict' }),
|
||
idempotencyKey: text('idempotency_key').notNull(),
|
||
correlationId: text('correlation_id').notNull(),
|
||
status: hierarchyOutboxStatusEnum('status').notNull().default('pending'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
|
||
},
|
||
(t) => [
|
||
uniqueIndex('hierarchy_outbox_event_idx').on(t.eventId),
|
||
uniqueIndex('hierarchy_outbox_idempotency_idx').on(t.idempotencyKey),
|
||
index('hierarchy_outbox_status_created_idx').on(t.status, t.createdAt),
|
||
],
|
||
);
|