Files
website/src/lib/server/db/schema/ai.ts

174 lines
5.4 KiB
TypeScript

import { sql } from 'drizzle-orm';
import {
check,
date,
doublePrecision,
index,
integer,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { aiSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
// The AI tracker (/trackers/ai). No live tracking: rows arrive through the idempotent
// import scripts (scripts/ai-news-import.ts, scripts/ai-bots-import.ts) that Daisy runs on
// the server, plus the owner's manual usage form. News is public; bot_status and
// usage_entries are owner-only (checked in the server loads and actions). Like tasks and
// health, the schema is granted to the app role only (no personal_ro).
export const AI_NEWS_SECTIONS = ['ai', 'software'] as const;
export type AiNewsSection = (typeof AI_NEWS_SECTIONS)[number];
export const AI_PROVIDERS = ['claude', 'chatgpt', 'gemini', 'api'] as const;
export type AiProvider = (typeof AI_PROVIDERS)[number];
/** scheduled = a scheduled task in the provider app; ccusage = ccusage on the server. */
export const AI_USAGE_SOURCES = ['scheduled', 'ccusage', 'export', 'manual'] as const;
export type AiUsageSource = (typeof AI_USAGE_SOURCES)[number];
/** Digest items from Ilya's weekday AI/software digest; `url` is the import key. */
export const aiNewsItems = aiSchema.table(
'news_items',
{
id: id(),
date: date('date').notNull(),
title: text('title').notNull(),
url: text('url').notNull(),
note: text('note'),
section: text('section').notNull().default('ai'),
...timestamps
},
(t) => [
uniqueIndex('news_items_url_key').on(t.url),
index('news_items_date_idx').on(t.date),
check('news_items_section_check', sql`${t.section} IN ('ai','software')`),
check('news_items_title_check', sql`length(btrim(${t.title})) > 0`)
]
);
/** One row per Grok Bot agent: short neutral summaries only, never raw transcripts. */
export const aiBotStatus = aiSchema.table(
'bot_status',
{
id: id(),
name: text('name').notNull(),
focus: text('focus'),
recent: text('recent'),
lastActive: timestamptz('last_active'),
...timestamps
},
(t) => [
uniqueIndex('bot_status_name_key').on(t.name),
check('bot_status_name_check', sql`length(btrim(${t.name})) > 0`)
]
);
/** Dated usage numbers per provider and metric (one value per day, metric and source). */
export const aiUsageEntries = aiSchema.table(
'usage_entries',
{
id: id(),
provider: text('provider').notNull(),
date: date('date').notNull(),
metric: text('metric').notNull(),
value: doublePrecision('value').notNull(),
unit: text('unit'),
source: text('source').notNull().default('manual'),
note: text('note'),
...timestamps
},
(t) => [
uniqueIndex('usage_entries_day_key').on(t.provider, t.metric, t.date, t.source),
index('usage_entries_provider_idx').on(t.provider, t.metric, t.date),
check(
'usage_entries_provider_check',
sql`${t.provider} IN ('claude','chatgpt','gemini','api')`
),
check(
'usage_entries_source_check',
sql`${t.source} IN ('scheduled','ccusage','export','manual')`
),
check('usage_entries_metric_check', sql`length(btrim(${t.metric})) > 0`)
]
);
/**
* Setup tab (owner-only): how each AI provider is set up. One row per provider card; entries
* are grouped into sections. Plan/API keys are labels only: the app refuses values that look
* like a secret, and nothing here is ever a key value.
*/
export const AI_SETUP_SECTIONS = [
'connections',
'skills',
'instructions',
'plugins',
'routines',
'agents',
'plan',
'notes'
] as const;
export type AiSetupSection = (typeof AI_SETUP_SECTIONS)[number];
export const AI_SETUP_SCOPES = ['global', 'per-repo', 'app'] as const;
export type AiSetupScope = (typeof AI_SETUP_SCOPES)[number];
export const AI_SETUP_COLORS = [
'orange',
'green',
'blue',
'violet',
'pink',
'sky',
'amber',
'zinc'
] as const;
export const aiSetupProviders = aiSchema.table(
'provider_setup_providers',
{
id: id(),
name: text('name').notNull(),
/** The products this card covers, e.g. "claude.ai + Claude Code". */
products: text('products'),
color: text('color').notNull().default('zinc'),
sortOrder: integer('sort_order').notNull().default(0),
...timestamps
},
(t) => [
uniqueIndex('provider_setup_providers_name_key').on(t.name),
check('provider_setup_providers_name_check', sql`length(btrim(${t.name})) > 0`),
check(
'provider_setup_providers_color_check',
sql`${t.color} IN ('orange','green','blue','violet','pink','sky','amber','zinc')`
)
]
);
export const aiSetupEntries = aiSchema.table(
'provider_setup_entries',
{
id: id(),
providerId: integer('provider_id')
.notNull()
.references(() => aiSetupProviders.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
section: text('section').notNull(),
name: text('name').notNull(),
note: text('note'),
scope: text('scope'),
link: text('link'),
/** Free markdown text (custom instructions, longer notes). */
body: text('body'),
sortOrder: integer('sort_order').notNull().default(0),
...timestamps
},
(t) => [
index('provider_setup_entries_provider_idx').on(t.providerId, t.section, t.sortOrder),
check(
'provider_setup_entries_section_check',
sql`${t.section} IN ('connections','skills','instructions','plugins','routines','agents','plan','notes')`
),
check(
'provider_setup_entries_scope_check',
sql`${t.scope} IS NULL OR ${t.scope} IN ('global','per-repo','app')`
),
check('provider_setup_entries_name_check', sql`length(btrim(${t.name})) > 0`)
]
);