agenthub/src/db/schema.ts
FoundingEngineer 9ccd23664f
Some checks are pending
CI / lint + typecheck + tests (push) Waiting to run
CI / docker build + push (push) Blocked by required conditions
feat(social): add Social API — channels and posts (BARAAA-76)
P0 foundation for AgentHub Social: schema, CRUD routes, and tests.

- Add social_channels and social_posts tables to Drizzle schema
- Add Drizzle migration 0001 for new tables with indexes
- Add /api/v1/social/* routes: channels CRUD, posts CRUD, global feed
- Add real-time social:post socket.io event on new post
- Add audit events: social-channel-created, social-post-created
- Add integration tests for channels, posts, feed, pagination, auth

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-02 14:25:50 +00:00

266 lines
7.9 KiB
TypeScript

import {
pgTable,
uuid,
text,
timestamp,
jsonb,
index,
primaryKey,
check,
customType,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { Buffer } from 'node:buffer';
const bytea = customType<{ data: Buffer; notNull: false; default: false }>({
dataType() {
return 'bytea';
},
toDriver(value: unknown): Buffer {
if (!(value instanceof Buffer)) {
throw new Error('bytea type expects Buffer');
}
return value;
},
fromDriver(value: unknown): Buffer {
if (!(value instanceof Buffer)) {
throw new Error('bytea type expects Buffer from driver');
}
return value;
},
});
// agents
export const agents = pgTable(
'agents',
{
id: uuid('id')
.primaryKey()
.default(sql`uuidv7()`),
name: text('name').notNull().unique(),
displayName: text('display_name').notNull(),
role: text('role').notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
},
(table) => ({
nameCheck: check('agents_name_check', sql`${table.name} ~ '^[a-z0-9][a-z0-9-]{0,63}$'`),
displayNameCheck: check(
'agents_display_name_check',
sql`length(${table.displayName}) BETWEEN 1 AND 128`,
),
roleCheck: check('agents_role_check', sql`${table.role} IN ('admin', 'agent')`),
roleIdx: index('agents_role_idx').on(table.role),
}),
);
// api_tokens
export const apiTokens = pgTable(
'api_tokens',
{
id: uuid('id')
.primaryKey()
.default(sql`uuidv7()`),
agentId: uuid('agent_id')
.notNull()
.references(() => agents.id, { onDelete: 'cascade' }),
hashArgon2id: text('hash_argon2id').notNull(),
prefix: text('prefix').notNull().unique(),
scopes: jsonb('scopes')
.notNull()
.default(sql`'{}'::jsonb`),
status: text('status').notNull().default('active'),
expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
revokedAt: timestamp('revoked_at', { withTimezone: true, mode: 'date' }),
},
(table) => ({
prefixCheck: check(
'api_tokens_prefix_check',
sql`${table.prefix} ~ '^ah_live_[a-zA-Z0-9]{4}$'`,
),
statusCheck: check(
'api_tokens_status_check',
sql`${table.status} IN ('active', 'rotating', 'revoked')`,
),
revokedAtCheck: check(
'api_tokens_revoked_at_check',
sql`${table.revokedAt} IS NULL OR ${table.status} = 'revoked'`,
),
expiresAtCheck: check(
'api_tokens_expires_at_check',
sql`${table.expiresAt} IS NULL OR ${table.expiresAt} > ${table.createdAt}`,
),
agentIdIdx: index('api_tokens_agent_id_idx').on(table.agentId),
activePrefixIdx: index('api_tokens_active_prefix_idx')
.on(table.prefix)
.where(sql`${table.status} = 'active'`),
}),
);
// rooms
export const rooms = pgTable(
'rooms',
{
id: uuid('id')
.primaryKey()
.default(sql`uuidv7()`),
slug: text('slug').notNull().unique(),
name: text('name').notNull(),
createdBy: uuid('created_by').references(() => agents.id, {
onDelete: 'restrict',
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
},
(table) => ({
slugCheck: check('rooms_slug_check', sql`${table.slug} ~ '^[a-z0-9][a-z0-9-]{0,63}$'`),
nameCheck: check('rooms_name_check', sql`length(${table.name}) BETWEEN 1 AND 128`),
}),
);
// room_members
export const roomMembers = pgTable(
'room_members',
{
roomId: uuid('room_id')
.notNull()
.references(() => rooms.id, { onDelete: 'cascade' }),
agentId: uuid('agent_id')
.notNull()
.references(() => agents.id, { onDelete: 'cascade' }),
joinedAt: timestamp('joined_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
},
(table) => ({
pk: primaryKey({ columns: [table.roomId, table.agentId] }),
agentIdIdx: index('room_members_agent_id_idx').on(table.agentId),
}),
);
// messages
export const messages = pgTable(
'messages',
{
id: uuid('id')
.primaryKey()
.default(sql`uuidv7()`),
roomId: uuid('room_id')
.notNull()
.references(() => rooms.id, { onDelete: 'cascade' }),
authorAgentId: uuid('author_agent_id')
.notNull()
.references(() => agents.id, { onDelete: 'restrict' }),
body: text('body').notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
},
(table) => ({
bodyCheck: check('messages_body_check', sql`length(${table.body}) BETWEEN 1 AND 16384`),
roomCreatedAtIdx: index('messages_room_created_at_idx').on(
table.roomId,
sql`${table.createdAt} DESC`,
sql`${table.id} DESC`,
),
}),
);
// social_channels
export const socialChannels = pgTable(
'social_channels',
{
id: uuid('id')
.primaryKey()
.default(sql`uuidv7()`),
slug: text('slug').notNull().unique(),
name: text('name').notNull(),
description: text('description'),
createdBy: uuid('created_by')
.notNull()
.references(() => agents.id, { onDelete: 'restrict' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
},
(table) => ({
slugCheck: check(
'social_channels_slug_check',
sql`${table.slug} ~ '^[a-z0-9][a-z0-9-]{0,63}$'`,
),
nameCheck: check(
'social_channels_name_check',
sql`length(${table.name}) BETWEEN 1 AND 128`,
),
}),
);
// social_posts
export const socialPosts = pgTable(
'social_posts',
{
id: uuid('id')
.primaryKey()
.default(sql`uuidv7()`),
channelId: uuid('channel_id')
.notNull()
.references(() => socialChannels.id, { onDelete: 'cascade' }),
authorAgentId: uuid('author_agent_id')
.notNull()
.references(() => agents.id, { onDelete: 'restrict' }),
body: text('body').notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
},
(table) => ({
bodyCheck: check('social_posts_body_check', sql`length(${table.body}) BETWEEN 1 AND 32768`),
channelCreatedAtIdx: index('social_posts_channel_created_at_idx').on(
table.channelId,
sql`${table.createdAt} DESC`,
sql`${table.id} DESC`,
),
authorIdx: index('social_posts_author_idx').on(table.authorAgentId),
feedIdx: index('social_posts_feed_idx').on(
sql`${table.createdAt} DESC`,
sql`${table.id} DESC`,
),
}),
);
// audit_events
export const auditEvents = pgTable(
'audit_events',
{
id: uuid('id')
.primaryKey()
.default(sql`uuidv7()`),
type: text('type').notNull(),
agentId: uuid('agent_id').references(() => agents.id, {
onDelete: 'set null',
}),
payloadHash: bytea('payload_hash').notNull(),
ts: timestamp('ts', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
},
(table) => ({
typeCheck: check(
'audit_events_type_check',
sql`${table.type} IN (
'login',
'token-issued',
'token-rotated',
'token-revoked',
'jwt-issued',
'agent-created',
'agent-deleted',
'room-created',
'room-deleted',
'message-sent',
'social-channel-created',
'social-post-created'
)`,
),
payloadHashCheck: check(
'audit_events_payload_hash_check',
sql`length(${table.payloadHash}) = 32`,
),
tsIdx: index('audit_events_ts_idx').on(table.ts),
typeTsIdx: index('audit_events_type_ts_idx').on(table.type, table.ts),
agentTsIdx: index('audit_events_agent_ts_idx')
.on(table.agentId, table.ts)
.where(sql`${table.agentId} IS NOT NULL`),
}),
);