diff --git a/.env.example b/.env.example index 0ecb7a4dd0..42fdb3c8a6 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,22 @@ ADMIN_DATABASE_URL=postgresql://user:password@postgres-admin:5432/pagespace_admi # (infrastructure/UPGRADE.md, #890 Phase 2 era-fork guard). AUDIT_CHAINER_ALLOW_GENESIS=true +# ClickHouse analytics tier (#890 Phase 3) — OFF by default (ships dark). +# Only the exact value CLICKHOUSE_ENABLED=true turns it on; when off, the +# analytics tables keep writing to the main Postgres unchanged. When on with +# missing/invalid config below, the client fail-fasts at init. Production uses +# cloud-managed ClickHouse Cloud; real credentials are secrets (`fly secrets +# set` / GitHub Actions secrets) — NEVER commit real values, placeholders only. +# Local dev: the docker-compose `clickhouse` service serves http://localhost:8123 +# with the dev credentials below (db pagespace_analytics, user/password). +# CLICKHOUSE_ENABLED=true +# CLICKHOUSE_HOST=your-cluster.clickhouse.cloud +# CLICKHOUSE_USER=default +# CLICKHOUSE_PASSWORD=your_clickhouse_password_here +# CLICKHOUSE_DATABASE=pagespace_analytics +# Or a full URL instead of CLICKHOUSE_HOST (local dev container): +# CLICKHOUSE_URL=http://localhost:8123 + # Authentication & Security PROCESSOR_AUTH_REQUIRED=true PROCESSOR_UPLOAD_RATE_LIMIT=100 diff --git a/apps/admin/.env.example b/apps/admin/.env.example index 9e9c9a488b..817dc0666b 100644 --- a/apps/admin/.env.example +++ b/apps/admin/.env.example @@ -1,6 +1,14 @@ # Database DATABASE_URL=postgresql://postgres:password@localhost:5432/pagespace +# ClickHouse analytics tier (#890 Phase 3) — OFF by default; only the exact +# value 'true' enables. Server-side secrets: placeholders only, never real values. +# CLICKHOUSE_ENABLED=true +# CLICKHOUSE_HOST=your-cluster.clickhouse.cloud +# CLICKHOUSE_USER=default +# CLICKHOUSE_PASSWORD=your_clickhouse_password_here +# CLICKHOUSE_DATABASE=pagespace_analytics + # Session auth (must match web app) CSRF_SECRET=change-me-min-32-chars-long-secret diff --git a/apps/admin/src/__tests__/instrumentation.test.ts b/apps/admin/src/__tests__/instrumentation.test.ts new file mode 100644 index 0000000000..a6223d4c3a --- /dev/null +++ b/apps/admin/src/__tests__/instrumentation.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; + +// #890 Phase 3 FIX: admin is a ClickHouse composition root too (its +// monitoring readers and logger-database writers run in this process) — +// a half-configured deploy must crash at boot, and graceful shutdown must +// drain the CH insert buffers. Draining + termination is owned by the shared +// logger's flush → drain → exit handler, which register() initializes; the +// composition root registers no bespoke signal listener. + +vi.mock('@pagespace/lib/observability/clickhouse-client', () => ({ + probeClickHouseStartup: vi.fn(() => ({ mode: 'disabled', reason: 'flag off' })), +})); +// Mocked so register()'s init import does not construct the real logger +// singleton (which would register real signal handlers + a flush timer on the +// test process). Its shutdown-owner behavior is covered by +// packages/lib graceful-shutdown.test.ts. +vi.mock('@pagespace/lib/logging/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { register } from '../instrumentation'; +import { probeClickHouseStartup } from '@pagespace/lib/observability/clickhouse-client'; + +describe('admin instrumentation — ClickHouse composition-root wiring (#890 Phase 3 FIX)', () => { + let onSpy: MockInstance; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(probeClickHouseStartup).mockImplementation(() => ({ + mode: 'disabled', + reason: 'flag off', + })); + vi.stubEnv('NEXT_RUNTIME', 'nodejs'); + onSpy = vi.spyOn(process, 'on'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + onSpy.mockRestore(); + }); + + it('given nodejs startup, register() should probe the ClickHouse mode', async () => { + await register(); + + expect(probeClickHouseStartup).toHaveBeenCalled(); + }); + + it('given a misconfigured deploy, register() should REJECT so the server crashes at boot', async () => { + vi.mocked(probeClickHouseStartup).mockImplementation(() => { + throw new Error('ClickHouse misconfigured: startup probe failed'); + }); + + await expect(register()).rejects.toThrow('ClickHouse misconfigured'); + }); + + it('given nodejs startup, register() should NOT register a bespoke SIGTERM/SIGINT listener — graceful shutdown (flush → drain → exit) is owned by the shared logger, which register() initializes', async () => { + await register(); + + const signalListeners = onSpy.mock.calls.filter( + ([event]) => event === 'SIGTERM' || event === 'SIGINT', + ); + expect(signalListeners).toHaveLength(0); + }); + + it('given the edge runtime, register() should not touch the ClickHouse wiring', async () => { + vi.stubEnv('NEXT_RUNTIME', 'edge'); + + await register(); + + expect(probeClickHouseStartup).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/admin/src/instrumentation.ts b/apps/admin/src/instrumentation.ts new file mode 100644 index 0000000000..63dae8150f --- /dev/null +++ b/apps/admin/src/instrumentation.ts @@ -0,0 +1,29 @@ +/** + * Admin composition root (Next.js instrumentation hook). + * + * ClickHouse analytics wiring (#890 Phase 3): this process both reads the + * analytics tier (monitoring queries) and writes to it (logger-database → + * insert adapters), so it must fail-fast on a half-configured deploy — the + * adapters absorb per-row errors by design, and a running process with the + * flag on but creds missing would silently black out all 4 tables. It must + * also drain the insert buffers on shutdown so deploys don't lose the + * in-memory window (up to 500 rows/table). + */ +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + const { probeClickHouseStartup } = await import('@pagespace/lib/observability/clickhouse-client'); + const chMode = probeClickHouseStartup(); + console.log(`[Instrumentation] ClickHouse analytics tier: ${chMode.mode}`); + + // Initialize the shared logger so its flush → drain → exit shutdown handler + // (logging/logger.ts → createShutdownHandler) is installed at boot. That + // handler is the single, terminating owner of graceful shutdown: on + // SIGTERM/SIGINT it flushes buffered logs, drains the ClickHouse insert + // buffers (up to 500 rows/table, including direct adapter inserts), then + // exits. Initializing it here means an idle process that receives a signal + // before serving any request still drains and terminates, rather than a + // bespoke drain-only listener that suppresses Node's default termination + // without ever exiting. + await import('@pagespace/lib/logging/logger'); + } +} diff --git a/apps/admin/src/lib/__tests__/monitoring-queries-clickhouse-cutover.test.ts b/apps/admin/src/lib/__tests__/monitoring-queries-clickhouse-cutover.test.ts new file mode 100644 index 0000000000..916f4064c6 --- /dev/null +++ b/apps/admin/src/lib/__tests__/monitoring-queries-clickhouse-cutover.test.ts @@ -0,0 +1,243 @@ +/** + * Flag-gated READ cutover for the admin monitoring queries (#890 Phase 3 + * leaf 3): with CLICKHOUSE_ENABLED the 4 analytics tables are queried from + * ClickHouse (server-side lib readers); with the flag off the PG path runs + * exactly as before. getUserActivity is pinned as PG-only — it reads + * activityLogs (Phase 5 territory), not one of the 4 moved tables. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const resultQueue = vi.hoisted(() => [] as unknown[][]); + +const makeChain = vi.hoisted(() => () => { + const rows = resultQueue.length ? resultQueue.shift()! : []; + const promise = Promise.resolve(rows); + const chain: Record = {}; + chain.from = vi.fn(() => chain); + chain.innerJoin = vi.fn(() => chain); + chain.leftJoin = vi.fn(() => chain); + chain.where = vi.fn(() => chain); + chain.groupBy = vi.fn(() => chain); + chain.orderBy = vi.fn(() => chain); + chain.having = vi.fn(() => chain); + chain.limit = vi.fn(() => promise); + chain.then = (resolve: (v: unknown[]) => void, reject?: (e: unknown) => void) => + promise.then(resolve, reject); + return chain; +}); + +const mockSelect = vi.hoisted(() => vi.fn(() => makeChain())); + +// CH gate + readers (hoisted so the vi.mock factories can reference them). +const mockIsClickHouseEnabled = vi.hoisted(() => vi.fn(() => false)); +const fakeClient = vi.hoisted(() => ({ __fake: 'clickhouse-client' })); +const chReads = vi.hoisted(() => ({ + getLogsByLevel: vi.fn(), + getErrorTrends: vi.fn(), + getFailedLogins: vi.fn(), + getRecentErrors: vi.fn(), + getErrorPatterns: vi.fn(), + getVolumeOverTime: vi.fn(), + getTopEndpoints: vi.fn(), + getRequestErrorCounts: vi.fn(), + getResponseTimes: vi.fn(), + getSlowQueries: vi.fn(), + getEndpointPerformance: vi.fn(), + getActivityHeatmap: vi.fn(), + getMostActiveUsers: vi.fn(), + getFeatureUsage: vi.fn(), + getActiveUserCount: vi.fn(), +})); + +vi.mock('@pagespace/lib/observability/clickhouse-client', () => ({ + isClickHouseEnabled: mockIsClickHouseEnabled, + getClickHouseClient: vi.fn(() => fakeClient), +})); + +vi.mock('@pagespace/lib/observability/analytics-reads', () => chReads); + +vi.mock('@pagespace/db/db', () => ({ db: { select: mockSelect } })); + +vi.mock('@pagespace/db/schema/monitoring', () => ({ + aiUsageLogs: { id: 'AI_ID', timestamp: 'AI_TS', cost: 'AI_COST', provider: 'AI_PROVIDER', model: 'AI_MODEL', inputTokens: 'AI_IN', outputTokens: 'AI_OUT', totalTokens: 'AI_TOTAL', userId: 'AI_USER', metadata: 'AI_META', success: 'AI_SUCCESS' }, + systemLogs: { timestamp: 'SL_TS', level: 'SL_LEVEL', category: 'SL_CATEGORY', ip: 'SL_IP', metadata: 'SL_META' }, + errorLogs: { id: 'EL_ID', timestamp: 'EL_TS', message: 'EL_MSG', name: 'EL_NAME', stack: 'EL_STACK', endpoint: 'EL_ENDPOINT', userId: 'EL_USER' }, + apiMetrics: { timestamp: 'AM_TS', endpoint: 'AM_ENDPOINT', duration: 'AM_DURATION', statusCode: 'AM_STATUS', userId: 'AM_USER' }, + activityLogs: { timestamp: 'AL_TS', userId: 'AL_USER', operation: 'AL_OP' }, +})); + +vi.mock('@pagespace/db/schema/sessions', () => ({ + sessions: { userId: 'S_USER', lastUsedAt: 'S_LAST_USED', revokedAt: 'S_REVOKED' }, +})); + +vi.mock('@pagespace/db/schema/credits', () => ({ + creditLedger: { entryType: 'CL_ENTRY_TYPE', amountCents: 'CL_AMOUNT', chargeMillicents: 'CL_CHARGE', appliedCents: 'CL_APPLIED', realCostCents: 'CL_REAL_COST', aiUsageLogId: 'CL_AI_ID', createdAt: 'CL_CREATED', userId: 'CL_USER' }, + creditBalances: { userId: 'CB_USER', monthlyRemainingCents: 'CB_MONTHLY', topupRemainingCents: 'CB_TOPUP', debtCents: 'CB_DEBT' }, + creditHolds: { estCents: 'CH_EST', expiresAt: 'CH_EXPIRES' }, +})); + +vi.mock('@pagespace/db/schema/subscriptions', () => ({ + subscriptions: { userId: 'SUB_USER', status: 'SUB_STATUS', gifted: 'SUB_GIFTED', stripePriceId: 'SUB_PRICE_ID' }, +})); + +vi.mock('@pagespace/db/schema/auth', () => ({ + users: { id: 'U_ID', name: 'U_NAME', email: 'U_EMAIL', createdAt: 'U_CREATED', subscriptionTier: 'U_TIER' }, +})); + +vi.mock('@pagespace/db/operators', () => ({ + sql: (() => { + const tag = vi.fn(() => 'SQL') as unknown as { (..._a: unknown[]): string; raw: (s: string) => string }; + tag.raw = ((s: string) => s) as (s: string) => string; + return tag; + })(), + eq: vi.fn((col: unknown, val: unknown) => ({ type: 'eq', col, val })), + gte: vi.fn((col: unknown, val: unknown) => ({ type: 'gte', col, val })), + lte: vi.fn((col: unknown, val: unknown) => ({ type: 'lte', col, val })), + and: vi.fn((...args: unknown[]) => ({ type: 'and', args })), + or: vi.fn((...args: unknown[]) => ({ type: 'or', args })), + gt: vi.fn((col: unknown, val: unknown) => ({ type: 'gt', col, val })), + lt: vi.fn((col: unknown, val: unknown) => ({ type: 'lt', col, val })), + asc: vi.fn((col: unknown) => col), + desc: vi.fn((col: unknown) => col), + count: vi.fn(() => 'COUNT'), + inArray: vi.fn((col: unknown, vals: unknown) => ({ type: 'inArray', col, vals })), + isNull: vi.fn((col: unknown) => ({ type: 'isNull', col })), + isNotNull: vi.fn((col: unknown) => ({ type: 'isNotNull', col })), +})); + +vi.mock('@pagespace/lib/billing/credit-core', () => ({ + computeBalanceDrift: vi.fn(), + isNegativeMargin: vi.fn(), +})); + +vi.mock('@pagespace/lib/billing/credit-pricing', () => ({ + BALANCE_DRIFT_TOLERANCE_CENTS: 100, + NEGATIVE_MARGIN_FLOOR_BPS: 0, +})); + +vi.mock('@pagespace/lib/auth/user-repository', () => ({ + decryptUserDisplayFields: vi.fn(async (rows: unknown[]) => rows), +})); + +vi.mock('../stripe/client', () => ({ + stripe: { prices: { retrieve: vi.fn() } }, +})); + +import { + getSystemHealth, + getApiMetrics, + getErrorAnalytics, + getUserActivity, +} from '../monitoring-queries'; + +const TS = new Date('2026-07-01T10:30:00.000Z'); + +beforeEach(() => { + vi.clearAllMocks(); + resultQueue.length = 0; + mockSelect.mockImplementation(() => makeChain()); + mockIsClickHouseEnabled.mockReturnValue(false); +}); + +describe('given CLICKHOUSE_ENABLED off', () => { + it('should never touch the ClickHouse readers', async () => { + await getSystemHealth(); + await getApiMetrics(); + await getErrorAnalytics(); + for (const fn of Object.values(chReads)) expect(fn).not.toHaveBeenCalled(); + expect(mockSelect).toHaveBeenCalled(); + }); +}); + +describe('given CLICKHOUSE_ENABLED on', () => { + beforeEach(() => { + mockIsClickHouseEnabled.mockReturnValue(true); + }); + + it('getSystemHealth should read logs/errors from CH but active users from PG sessions', async () => { + chReads.getLogsByLevel.mockResolvedValue([{ level: 'error', count: 2 }]); + chReads.getRecentErrors.mockResolvedValue([ + { + id: 'e1', + timestamp: TS, + message: 'boom', + errorName: 'TypeError', + errorMessage: null, + endpoint: '/api/x', + userId: null, + }, + ]); + resultQueue.push([{ count: 5 }]); // sessions active-user query stays PG + + const result = await getSystemHealth(TS); + + expect(chReads.getLogsByLevel).toHaveBeenCalledWith(fakeClient, { + startDate: TS, + endDate: undefined, + }); + expect(chReads.getRecentErrors).toHaveBeenCalledWith( + fakeClient, + { startDate: TS, endDate: undefined }, + 20, + ); + expect(result.logsByLevel).toEqual([{ level: 'error', count: 2 }]); + // stack was null → falls back to message, exactly like the PG mapping + expect(result.recentErrors[0].errorMessage).toBe('boom'); + expect(result.activeUserCount).toBe(5); + expect(mockSelect).toHaveBeenCalledTimes(1); + }); + + it('getApiMetrics should aggregate in CH SQL and compute the error rate from countIf', async () => { + chReads.getVolumeOverTime.mockResolvedValue([ + { hour: TS, count: 4, avg_response_time: 12.5 }, + ]); + chReads.getTopEndpoints.mockResolvedValue([ + { endpoint: '/api/x', count: 4, avgResponseTime: 12.5 }, + ]); + chReads.getRequestErrorCounts.mockResolvedValue({ total: 8, errors: 2 }); + + const result = await getApiMetrics(TS); + + expect(result.volumeOverTime).toEqual([{ hour: TS, count: 4, avg_response_time: 12.5 }]); + expect(result.topEndpoints).toEqual([{ endpoint: '/api/x', count: 4, avgResponseTime: 12.5 }]); + expect(result.errorRate).toBe(25); + expect(result.totalRequests).toBe(8); + expect(mockSelect).not.toHaveBeenCalled(); + }); + + it('getApiMetrics should report a 0 error rate on an empty window', async () => { + chReads.getVolumeOverTime.mockResolvedValue([]); + chReads.getTopEndpoints.mockResolvedValue([]); + chReads.getRequestErrorCounts.mockResolvedValue({ total: 0, errors: 0 }); + + const result = await getApiMetrics(); + expect(result.errorRate).toBe(0); + expect(result.totalRequests).toBe(0); + }); + + it('getErrorAnalytics should keep the PG output massaging (other-category, count strings, masked emails)', async () => { + chReads.getErrorTrends.mockResolvedValue([{ hour: TS, category: null, count: 3 }]); + chReads.getErrorPatterns.mockResolvedValue([{ name: 'E', endpoint: null, count: 4 }]); + chReads.getFailedLogins.mockResolvedValue([ + { timestamp: TS, ip: '1.2.3.4', metadata: { detail: 'login failed for jane@example.com' } }, + ]); + + const result = await getErrorAnalytics(TS); + + expect(result.errorTrends).toEqual([{ hour: TS, category: 'other', count: '3' }]); + expect(result.errorPatterns).toEqual([{ name: 'E', category: 'general', count: 4 }]); + const metadata = result.failedLogins[0].metadata as { detail: string }; + expect(metadata.detail).not.toContain('jane@example.com'); + expect(mockSelect).not.toHaveBeenCalled(); + }); + + it('getUserActivity should STAY on PG — it reads activityLogs, not one of the 4 moved tables', async () => { + resultQueue.push([], [], []); + await getUserActivity(TS); + + expect(chReads.getActivityHeatmap).not.toHaveBeenCalled(); + expect(chReads.getMostActiveUsers).not.toHaveBeenCalled(); + expect(chReads.getFeatureUsage).not.toHaveBeenCalled(); + expect(mockSelect).toHaveBeenCalledTimes(3); + }); +}); diff --git a/apps/admin/src/lib/__tests__/monitoring-queries.parity.integration.test.ts b/apps/admin/src/lib/__tests__/monitoring-queries.parity.integration.test.ts new file mode 100644 index 0000000000..0a73e5989d --- /dev/null +++ b/apps/admin/src/lib/__tests__/monitoring-queries.parity.integration.test.ts @@ -0,0 +1,242 @@ +/** + * CH-vs-PG parity for the re-pointed admin monitoring readers (#890 Phase 3 + * leaf 3) — the acceptance backbone for the admin read cutover. + * + * Both stores are seeded with the SAME logical rows inside a random past + * hour-aligned window unique to this run, then each re-pointed reader runs + * flag-off (PG path) and flag-on (CH path) and the outputs are compared + * after normalization. The window sits 4–6 days in the past: recent enough + * for the CH TTLs (system_logs 30d is the tightest — ClickHouse drops + * already-expired rows at insert time), old enough that nothing else writes + * into it, and in a different band from the web parity suite so concurrent + * runs never overlap. + * + * Admin-specific pins: the active-user count stays on PG sessions in BOTH + * modes, and failedLogins metadata is email-masked identically on both + * paths. + * + * Requires DATABASE_URL → migrated Postgres AND the dev ClickHouse container + * (docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d + * clickhouse). Skips itself when either store is unreachable. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { get } from 'node:http'; +import { + getClickHouseClient, + type ClickHouseClient, +} from '@pagespace/lib/observability/clickhouse-client'; +import { db } from '@pagespace/db/db'; +import { like } from '@pagespace/db/operators'; +import { apiMetrics, systemLogs, errorLogs } from '@pagespace/db/schema/monitoring'; +import { ensureAnalyticsTables } from '@pagespace/lib/observability/clickhouse-ddl'; +import { + mapApiMetricToRow, + mapSystemLogToRow, + mapErrorLogToRow, +} from '@pagespace/lib/observability/analytics-rows'; +import { getSystemHealth, getApiMetrics, getErrorAnalytics } from '../monitoring-queries'; + +// PG DATE_TRUNC buckets in the server's local zone while CH buckets in UTC; +// production runs UTC, so the parity contract (and this test) is UTC. +process.env.TZ = 'UTC'; + +const CH_URL = process.env.CLICKHOUSE_URL ?? 'http://localhost:8123'; +const CH_ENV = { + CLICKHOUSE_ENABLED: 'true', + CLICKHOUSE_URL: CH_URL, + CLICKHOUSE_USER: process.env.CLICKHOUSE_USER ?? 'user', + CLICKHOUSE_PASSWORD: process.env.CLICKHOUSE_PASSWORD ?? 'password', + CLICKHOUSE_DATABASE: process.env.CLICKHOUSE_DATABASE ?? 'pagespace_analytics', +}; + +const chReachable = await new Promise((resolve) => { + const req = get(`${CH_URL}/ping`, { timeout: 2000 }, (res) => { + res.resume(); + resolve(res.statusCode === 200); + }); + req.on('timeout', () => req.destroy()); + req.on('error', () => resolve(false)); +}); + +const pgReachable = await (async () => { + try { + await db.select({ id: errorLogs.id }).from(errorLogs).limit(1); + return true; + } catch { + return false; + } +})(); + +// Random hour-aligned window 4–6 days ago (see header). +const WINDOW_START = new Date( + Math.floor((Date.now() - 144 * 3_600_000) / 3_600_000) * 3_600_000 + + Math.floor(Math.random() * 48) * 3_600_000, +); +const WINDOW_END = new Date(WINDOW_START.getTime() + 2 * 3_600_000); +const runId = `parity-admin-${WINDOW_START.getTime()}-${Math.floor(Math.random() * 1e6)}`; +const at = (minutes: number): Date => new Date(WINDOW_START.getTime() + minutes * 60_000); + +async function withClickHouse(fn: () => Promise): Promise { + const previous: Record = {}; + for (const [key, value] of Object.entries(CH_ENV)) { + previous[key] = process.env[key]; + process.env[key] = value; + } + try { + return await fn(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +const ms = (v: unknown): number => (v instanceof Date ? v.getTime() : new Date(String(v)).getTime()); +const num = (v: unknown): number | null => (v === null || v === undefined ? null : Math.round(Number(v) * 1e6) / 1e6); +const byKey = (key: (r: T) => string) => (a: T, b: T) => key(a).localeCompare(key(b)); + +let chClient: ClickHouseClient; + +describe.skipIf(!chReachable || !pgReachable)( + 'admin monitoring readers: ClickHouse path ≡ Postgres path on identically seeded data', + () => { + beforeAll(async () => { + chClient = (await withClickHouse(async () => getClickHouseClient()))!; + await ensureAnalyticsTables(chClient); + + const metricRows = [ + { id: `${runId}-m1`, timestamp: at(5), endpoint: '/api/pages', method: 'GET', statusCode: 200, duration: 80 }, + { id: `${runId}-m2`, timestamp: at(10), endpoint: '/api/pages', method: 'GET', statusCode: 500, duration: 240 }, + { id: `${runId}-m3`, timestamp: at(65), endpoint: '/api/drives', method: 'POST', statusCode: 200, duration: 120 }, + ] as const; + + const logRows = [ + { id: `${runId}-s1`, timestamp: at(6), level: 'info', message: 'ok', category: 'api' }, + { id: `${runId}-s2`, timestamp: at(12), level: 'error', message: 'blew up', category: undefined }, + { id: `${runId}-s3`, timestamp: at(66), level: 'warn', message: 'login failed', category: 'auth', ip: '5.5.5.5', metadata: { detail: 'login failed for jane@example.com' } }, + { id: `${runId}-s4`, timestamp: at(70), level: 'error', message: 'lockout', category: 'auth', metadata: { nested: { contact: 'bob@corp.io' } } }, + ] as const; + + const errorRows = [ + { id: `${runId}-e1`, timestamp: at(15), name: 'TypeError', message: 'boom', stack: 'trace1', endpoint: '/api/pages' }, + { id: `${runId}-e2`, timestamp: at(20), name: 'TypeError', message: 'bang', stack: undefined, endpoint: '/api/pages' }, + { id: `${runId}-e3`, timestamp: at(67), name: 'RangeError', message: 'oops', stack: 'trace3', endpoint: undefined }, + ] as const; + + await db.insert(apiMetrics).values(metricRows.map((r) => ({ ...r, method: r.method as 'GET' | 'POST' }))); + await db.insert(systemLogs).values(logRows.map((r) => ({ ...r, level: r.level as 'info' | 'warn' | 'error', category: r.category ?? null, metadata: 'metadata' in r ? r.metadata : null }))); + await db.insert(errorLogs).values(errorRows.map((r) => ({ ...r, stack: r.stack ?? null, endpoint: r.endpoint ?? null }))); + + const identity = (row: { id: string; timestamp: Date }) => ({ id: row.id, timestamp: row.timestamp }); + await chClient.insert({ table: 'api_metrics', values: metricRows.map((r) => mapApiMetricToRow(r, identity(r))), format: 'JSONEachRow' }); + await chClient.insert({ table: 'system_logs', values: logRows.map((r) => mapSystemLogToRow(r, identity(r))), format: 'JSONEachRow' }); + await chClient.insert({ table: 'error_logs', values: errorRows.map((r) => mapErrorLogToRow(r, identity(r))), format: 'JSONEachRow' }); + }, 30_000); + + afterAll(async () => { + delete process.env.CLICKHOUSE_ENABLED; + try { + await db.delete(apiMetrics).where(like(apiMetrics.id, `${runId}%`)); + await db.delete(systemLogs).where(like(systemLogs.id, `${runId}%`)); + await db.delete(errorLogs).where(like(errorLogs.id, `${runId}%`)); + } catch { + // best-effort cleanup + } + try { + for (const table of ['api_metrics', 'system_logs', 'error_logs']) { + await chClient.command({ query: `DELETE FROM ${table} WHERE id LIKE '${runId}%'` }); + } + } catch { + // lightweight deletes are best-effort; rows sit in a unique past window + } + await chClient?.close(); + }, 30_000); + + it('getSystemHealth: logsByLevel and recentErrors match; active users stay on PG sessions', async () => { + const pg = await getSystemHealth(WINDOW_START, WINDOW_END); + const ch = await withClickHouse(() => getSystemHealth(WINDOW_START, WINDOW_END)); + + const levels = (r: typeof pg) => + r.logsByLevel.map((l) => ({ level: l.level, count: Number(l.count) })).sort(byKey((l) => l.level)); + expect(levels(ch)).toEqual(levels(pg)); + expect(levels(pg)).toEqual([ + { level: 'error', count: 2 }, + { level: 'info', count: 1 }, + { level: 'warn', count: 1 }, + ]); + + const errors = (r: typeof pg) => + r.recentErrors.map((e) => ({ + id: e.id, + timestamp: ms(e.timestamp), + message: e.message, + errorName: e.errorName, + errorMessage: e.errorMessage, + endpoint: e.endpoint, + userId: e.userId, + })); + expect(errors(ch)).toEqual(errors(pg)); + expect(errors(pg).map((e) => e.id)).toEqual([`${runId}-e3`, `${runId}-e2`, `${runId}-e1`]); + expect(errors(pg)[1].errorMessage).toBe('bang'); // null stack → message fallback + + // sessions-based count: same PG query in both modes → identical value + expect(ch.activeUserCount).toBe(pg.activeUserCount); + }); + + it('getApiMetrics: volume, top endpoints, error rate and totals match (feeds the overview route)', async () => { + const pg = await getApiMetrics(WINDOW_START, WINDOW_END); + const ch = await withClickHouse(() => getApiMetrics(WINDOW_START, WINDOW_END)); + + const volume = (r: typeof pg) => + r.volumeOverTime + .map((v) => ({ hour: ms(v.hour), count: Number(v.count), avg: num(v.avg_response_time) })) + .sort((a, b) => a.hour - b.hour); + expect(volume(ch)).toEqual(volume(pg)); + expect(volume(pg)).toEqual([ + { hour: WINDOW_START.getTime(), count: 2, avg: 160 }, + { hour: WINDOW_START.getTime() + 3_600_000, count: 1, avg: 120 }, + ]); + + const top = (r: typeof pg) => + r.topEndpoints + .map((t) => ({ endpoint: t.endpoint, count: Number(t.count), avg: num(t.avgResponseTime) })) + .sort(byKey((t) => t.endpoint)); + expect(top(ch)).toEqual(top(pg)); + + expect(ch.totalRequests).toBe(pg.totalRequests); + expect(pg.totalRequests).toBe(3); + expect(num(ch.errorRate)).toEqual(num(pg.errorRate)); + expect(num(pg.errorRate)).toEqual(num(100 / 3)); + }); + + it('getErrorAnalytics: trends, patterns and failed logins match — emails masked on BOTH paths', async () => { + const pg = await getErrorAnalytics(WINDOW_START, WINDOW_END); + const ch = await withClickHouse(() => getErrorAnalytics(WINDOW_START, WINDOW_END)); + + const trends = (r: typeof pg) => + r.errorTrends + .map((t) => ({ hour: ms(t.hour), category: t.category, count: t.count })) + .sort(byKey((t) => `${t.hour}-${t.category}`)); + expect(trends(ch)).toEqual(trends(pg)); + expect(trends(pg).map((t) => t.category).sort()).toEqual(['auth', 'other']); + + const patterns = (r: typeof pg) => + r.errorPatterns + .map((p) => ({ name: p.name, category: p.category, count: Number(p.count) })) + .sort(byKey((p) => `${p.name}-${p.category}`)); + expect(patterns(ch)).toEqual(patterns(pg)); + expect(patterns(pg)).toEqual([ + { name: 'RangeError', category: 'general', count: 1 }, + { name: 'TypeError', category: '/api/pages', count: 2 }, + ]); + + const logins = (r: typeof pg) => + r.failedLogins.map((l) => ({ timestamp: ms(l.timestamp), ip: l.ip, metadata: l.metadata })); + expect(logins(ch)).toEqual(logins(pg)); + const serialized = JSON.stringify(logins(pg)) + JSON.stringify(logins(ch)); + expect(serialized).not.toContain('jane@example.com'); + expect(serialized).not.toContain('bob@corp.io'); + }); + }, +); diff --git a/apps/admin/src/lib/monitoring-queries.ts b/apps/admin/src/lib/monitoring-queries.ts index 78fd43eaaf..a08c43aff3 100644 --- a/apps/admin/src/lib/monitoring-queries.ts +++ b/apps/admin/src/lib/monitoring-queries.ts @@ -17,11 +17,66 @@ import { stripe } from './stripe/client'; import type { SubscriptionTier } from '@pagespace/lib/services/subscription-utils'; import { decryptUserDisplayFields } from '@pagespace/lib/auth/user-repository'; import { maskEmail } from '@pagespace/lib/audit/mask-email'; +import { isClickHouseEnabled, getClickHouseClient } from '@pagespace/lib/observability/clickhouse-client'; +import { + getLogsByLevel as chGetLogsByLevel, + getRecentErrors as chGetRecentErrors, + getVolumeOverTime as chGetVolumeOverTime, + getTopEndpoints as chGetTopEndpoints, + getRequestErrorCounts as chGetRequestErrorCounts, + getErrorTrends as chGetErrorTrends, + getErrorPatterns as chGetErrorPatterns, + getFailedLogins as chGetFailedLogins, +} from '@pagespace/lib/observability/analytics-reads'; +import type { ClickHouseClient } from '@pagespace/lib/observability/clickhouse-client'; + +/** + * Post-cutover (#890 Phase 3) new analytics rows land only in ClickHouse, so + * the readers over the 4 moved tables (apiMetrics, systemLogs, + * userActivities, errorLogs) query CH when the flag is on — server-side + * only, aggregations in CH SQL — and hit PG exactly as before when it is + * off. Returns null when the tier is off so callers fall through to PG. + * NOTE: getUserActivity is NOT gated — it reads activityLogs (Phase 5), not + * one of the moved tables; the sessions-based active-user count stays PG too. + */ +function clickHouseClientIfEnabled(): ClickHouseClient | null { + return isClickHouseEnabled() ? getClickHouseClient() : null; +} + +/** Active users: distinct sessions touched in the last 15 minutes (main PG in both modes). */ +async function getActiveSessionUserCount(): Promise { + const fifteenMinutesAgo = new Date(Date.now() - 15 * 60 * 1000); + const activeUsers = await db + .select({ + count: sql`COUNT(DISTINCT ${sessions.userId})::int`, + }) + .from(sessions) + .where(and(gte(sessions.lastUsedAt, fifteenMinutesAgo), isNull(sessions.revokedAt))); + return activeUsers[0]?.count || 0; +} /** * Get system health overview */ export async function getSystemHealth(startDate?: Date, endDate?: Date) { + const chClient = clickHouseClientIfEnabled(); + if (chClient) { + const window = { startDate, endDate }; + const [logsByLevel, recentErrors, activeUserCount] = await Promise.all([ + chGetLogsByLevel(chClient, window), + chGetRecentErrors(chClient, window, 20), + getActiveSessionUserCount(), + ]); + return { + logsByLevel, + recentErrors: recentErrors.map((entry) => ({ + ...entry, + errorMessage: entry.errorMessage || entry.message, + })), + activeUserCount, + }; + } + const logConditions: SQL[] = []; if (startDate) { @@ -61,13 +116,7 @@ export async function getSystemHealth(startDate?: Date, endDate?: Date) { // Active users: distinct sessions touched in last 15 minutes (sessions.lastUsedAt is // updated non-blocking on every authenticated request — reliable signal) - const fifteenMinutesAgo = new Date(Date.now() - 15 * 60 * 1000); - const activeUsers = await db - .select({ - count: sql`COUNT(DISTINCT ${sessions.userId})::int`, - }) - .from(sessions) - .where(and(gte(sessions.lastUsedAt, fifteenMinutesAgo), isNull(sessions.revokedAt))); + const activeUserCount = await getActiveSessionUserCount(); return { logsByLevel: logsByLevel.map((entry) => ({ @@ -78,7 +127,7 @@ export async function getSystemHealth(startDate?: Date, endDate?: Date) { ...entry, errorMessage: entry.errorMessage || entry.message, })), - activeUserCount: activeUsers[0]?.count || 0, + activeUserCount, }; } @@ -86,8 +135,24 @@ export async function getSystemHealth(startDate?: Date, endDate?: Date) { * Get API metrics */ export async function getApiMetrics(startDate?: Date, endDate?: Date) { + const chClient = clickHouseClientIfEnabled(); + if (chClient) { + const window = { startDate, endDate }; + const [volumeOverTime, topEndpoints, requestCounts] = await Promise.all([ + chGetVolumeOverTime(chClient, window), + chGetTopEndpoints(chClient, window), + chGetRequestErrorCounts(chClient, window), + ]); + return { + volumeOverTime, + topEndpoints, + errorRate: requestCounts.total > 0 ? (requestCounts.errors / requestCounts.total) * 100 : 0, + totalRequests: requestCounts.total, + }; + } + const conditions = []; - + if (startDate) { conditions.push(gte(apiMetrics.timestamp, startDate)); } @@ -241,6 +306,33 @@ function maskEmailsInMetadata(metadata: unknown): Record | null * Get error analytics */ export async function getErrorAnalytics(startDate?: Date, endDate?: Date) { + const chClient = clickHouseClientIfEnabled(); + if (chClient) { + const window = { startDate, endDate }; + const [errorTrendsRaw, errorPatternsRaw, failedLogins] = await Promise.all([ + chGetErrorTrends(chClient, window), + chGetErrorPatterns(chClient, window), + chGetFailedLogins(chClient, window), + ]); + return { + errorTrends: errorTrendsRaw.map((item) => ({ + hour: item.hour, + category: item.category ?? 'other', + count: item.count.toString(), + })), + errorPatterns: errorPatternsRaw.map((pattern) => ({ + name: pattern.name ?? 'Unknown Error', + category: pattern.endpoint ?? 'general', + count: pattern.count, + })), + failedLogins: failedLogins.map((login) => ({ + timestamp: login.timestamp, + ip: login.ip, + metadata: maskEmailsInMetadata(login.metadata), + })), + }; + } + const errorLevelConditions: SQL[] = [eq(systemLogs.level, 'error' as const)]; if (startDate) { diff --git a/apps/admin/vitest.config.ts b/apps/admin/vitest.config.ts index 94476ff9c3..29ba97340f 100644 --- a/apps/admin/vitest.config.ts +++ b/apps/admin/vitest.config.ts @@ -29,6 +29,7 @@ export default defineConfig({ { find: '@pagespace/db/schema/subscriptions', replacement: path.resolve(repoRoot, 'packages/db/src/schema/subscriptions.ts') }, { find: '@pagespace/db/schema/contact', replacement: path.resolve(repoRoot, 'packages/db/src/schema/contact.ts') }, { find: '@pagespace/db/schema/members', replacement: path.resolve(repoRoot, 'packages/db/src/schema/members.ts') }, + { find: /^@pagespace\/db\/(.+)$/, replacement: path.resolve(repoRoot, 'packages/db/src/$1') }, { find: '@pagespace/db', replacement: path.resolve(repoRoot, 'packages/db/src/index.ts') }, { find: '@pagespace/lib/auth/session-service', replacement: path.resolve(repoRoot, 'packages/lib/src/auth/session-service.ts') }, { find: '@pagespace/lib/auth/magic-link-service', replacement: path.resolve(repoRoot, 'packages/lib/src/auth/magic-link-service.ts') }, diff --git a/apps/processor/.env.example b/apps/processor/.env.example new file mode 100644 index 0000000000..c3d8d8bcc9 --- /dev/null +++ b/apps/processor/.env.example @@ -0,0 +1,13 @@ +# Database +DATABASE_URL=postgresql://user:password@localhost:5432/pagespace + +# Port +PORT=3003 + +# ClickHouse analytics tier (#890 Phase 3) — OFF by default; only the exact +# value 'true' enables. Server-side secrets: placeholders only, never real values. +# CLICKHOUSE_ENABLED=true +# CLICKHOUSE_HOST=your-cluster.clickhouse.cloud +# CLICKHOUSE_USER=default +# CLICKHOUSE_PASSWORD=your_clickhouse_password_here +# CLICKHOUSE_DATABASE=pagespace_analytics diff --git a/apps/processor/src/__tests__/server-clickhouse-wiring.test.ts b/apps/processor/src/__tests__/server-clickhouse-wiring.test.ts new file mode 100644 index 0000000000..ce829c0387 --- /dev/null +++ b/apps/processor/src/__tests__/server-clickhouse-wiring.test.ts @@ -0,0 +1,34 @@ +/** + * Pins the processor composition-root ClickHouse wiring (#890 Phase 3 FIX). + * + * server.ts executes start() at import time, so it cannot be imported in a + * unit test; these are source pins on the two invariants: + * - startup probes the CH mode (a half-configured deploy crashes at boot + * via the try/catch → process.exit(1) around start()); + * - shutdown drains the analytics insert buffers BEFORE the queue manager + * shuts down, on SIGTERM and SIGINT. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import path from 'path'; + +const source = readFileSync(path.resolve(__dirname, '../server.ts'), 'utf8'); + +describe('processor server — ClickHouse composition-root wiring', () => { + it('probes the ClickHouse mode at startup, before the server listens', () => { + const probeIndex = source.indexOf('probeClickHouseStartup()'); + const listenIndex = source.indexOf('app.listen'); + expect(probeIndex).toBeGreaterThan(-1); + expect(listenIndex).toBeGreaterThan(-1); + expect(probeIndex).toBeLessThan(listenIndex); + }); + + it('drains the analytics insert buffers on shutdown', () => { + expect(source).toContain('drainAnalyticsInserts()'); + }); + + it('registers the shutdown path for both SIGTERM and SIGINT', () => { + expect(source).toMatch(/process\.on\('SIGTERM'/); + expect(source).toMatch(/process\.on\('SIGINT'/); + }); +}); diff --git a/apps/processor/src/server.ts b/apps/processor/src/server.ts index 91cfa3915a..544822353e 100644 --- a/apps/processor/src/server.ts +++ b/apps/processor/src/server.ts @@ -17,6 +17,8 @@ import { authenticateService, requireScope } from './middleware/auth'; import { requireResourceBinding, requirePageBinding } from './middleware/resource-binding'; import { validateCorsOrigin } from './utils/cors-validation'; import { loadSiemConfig, type AuditLogSource } from './services/siem-adapter'; +import { probeClickHouseStartup } from '@pagespace/lib/observability/clickhouse-client'; +import { drainAnalyticsInserts } from '@pagespace/lib/observability/analytics-inserts'; import { SIEM_SOURCES, CURSOR_INIT_SENTINEL } from './services/siem-sources'; import { buildSiemHealth, type SiemHealthResponse } from './services/siem-health-builder'; import { readCursorSnapshots } from './workers/siem-cursor-reader'; @@ -368,6 +370,13 @@ app.use((err: any, req: express.Request, res: express.Response, next: express.Ne // Initialize and start server async function start() { try { + // Fail-fast: a half-configured ClickHouse deploy (flag on, creds missing) + // must crash here — the insert adapters absorb per-row errors by design, + // so a running process would silently drop all 4 analytics tables' + // telemetry (#890 Phase 3). Throws into the catch below → process.exit(1). + const chMode = probeClickHouseStartup(); + console.log(`✓ ClickHouse analytics tier: ${chMode.mode}`); + // Initialize content store await contentStore.initialize(); console.log('✓ Content store initialized'); @@ -394,12 +403,34 @@ async function start() { } }, 60 * 60 * 1000); - // Graceful shutdown - process.on('SIGTERM', async () => { - console.log('SIGTERM received, shutting down gracefully...'); - await queueManager.shutdown(); + // Graceful shutdown: drain the CH insert buffers first (workers write + // analytics rows through them — up to 500 rows/table sit in memory), + // then stop the queue manager. + let shuttingDown = false; + const shutdown = async (signal: string) => { + if (shuttingDown) return; + shuttingDown = true; + console.log(`${signal} received, shutting down gracefully...`); + // Each step is guarded so a rejection can never skip process.exit(0) — + // otherwise the process would hang until the runtime SIGKILLs it (mirrors + // the per-step guards in packages/lib graceful-shutdown.ts). + try { + await drainAnalyticsInserts(); + } catch (error) { + console.error('Analytics drain failed during shutdown:', error); + } + try { + await queueManager.shutdown(); + } catch (error) { + console.error('Queue manager shutdown failed:', error); + } process.exit(0); - }); + }; + // Return the shutdown promise so callers/tests can await the full + // drain → queue-shutdown → exit chain; Node ignores the handler's + // return value at runtime (same fire-and-forget as an async handler). + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); } catch (error) { console.error('Failed to start processor service:', error); diff --git a/apps/processor/src/workers/account-erasure-worker.ts b/apps/processor/src/workers/account-erasure-worker.ts index a931b921e6..909b891564 100644 --- a/apps/processor/src/workers/account-erasure-worker.ts +++ b/apps/processor/src/workers/account-erasure-worker.ts @@ -32,6 +32,7 @@ import { createResendSuppressionClient } from '@pagespace/lib/compliance/erasure import { eraseAiProviderData } from '@pagespace/lib/compliance/erasure/ai-provider-erasure'; import { securityAudit } from '@pagespace/lib/audit/security-audit'; import { logActivity, getActorInfo } from '@pagespace/lib/monitoring/activity-logger'; +import { isClickHouseAnalyticsInPlay } from '@pagespace/lib/observability/clickhouse-client'; import { isOnPrem, isTenantMode } from '@pagespace/lib/deployment-mode'; import type { AccountErasureJobData } from '../types'; import { deleteUserAvatars } from '../api/avatar'; @@ -186,7 +187,13 @@ export async function runAccountErasureJob(data: AccountErasureJobData): Promise // Stripe lives in the web app (its SDK isn't bundled here); the web records // that step on the DSR row at enqueue time, so the durable job skips it. - const steps: RunnableStep[] = buildErasurePlan({ deploymentMode: mode }) + const steps: RunnableStep[] = buildErasurePlan({ + deploymentMode: mode, + // With CH configured at all (flag-independent), purge-monitoring becomes + // fatal: a CH purge failure must fail the run for retry, not complete it + // with the subject's error rows retained forever (error_logs has no TTL). + clickHouseInPlay: isClickHouseAnalyticsInPlay(), + }) .filter((step) => step.id !== 'stripe-customer') .map((step) => ({ id: step.id, diff --git a/apps/web/.env.example b/apps/web/.env.example index 49faa59054..af8f29b39a 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,6 +1,14 @@ # Database Configuration DATABASE_URL=postgresql://user:password@localhost:5432/pagespace +# ClickHouse analytics tier (#890 Phase 3) — OFF by default; only the exact +# value 'true' enables. Server-side secrets: placeholders only, never real values. +# CLICKHOUSE_ENABLED=true +# CLICKHOUSE_HOST=your-cluster.clickhouse.cloud +# CLICKHOUSE_USER=default +# CLICKHOUSE_PASSWORD=your_clickhouse_password_here +# CLICKHOUSE_DATABASE=pagespace_analytics + # CSRF Protection CSRF_SECRET=generate_another_secure_64_character_hex_string_here diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 330c57bf98..229b4e9f22 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -136,6 +136,15 @@ export const nextConfig: NextConfig = { process: false, os: false, }; + // The ClickHouse analytics client (#890 Phase 3) is server-only and + // imports node:os/node:http at module scope, which resolve.fallback + // does not cover (node: prefix). Client bundles reach it transitively + // through @pagespace/lib's logging writers (dead code in the browser, + // same as pg above) — stub the whole package out. + config.resolve.alias = { + ...config.resolve.alias, + '@clickhouse/client': false, + }; const outputPath = config.output.path ?? path.join(__dirname, ".next"); config.plugins.push( diff --git a/apps/web/src/__tests__/instrumentation.test.ts b/apps/web/src/__tests__/instrumentation.test.ts new file mode 100644 index 0000000000..e1c0c495ea --- /dev/null +++ b/apps/web/src/__tests__/instrumentation.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; + +// #890 Phase 3 FIX: the composition root must (1) crash a half-configured +// deploy at startup — flag on + creds missing would otherwise silently black +// out all 4 analytics tables while enqueue() absorbs per-row errors — and +// (2) initialize the shared logger so its flush → drain → exit shutdown +// handler (the single, terminating owner of graceful shutdown) is installed +// at boot, draining the CH insert buffers on SIGTERM/SIGINT. The composition +// root itself registers no bespoke signal listener. + +vi.mock('@sentry/nextjs', () => ({ captureRequestError: vi.fn() })); +vi.mock('../../sentry.server.config', () => ({})); +vi.mock('@pagespace/lib/config/env-validation', () => ({ validateEnv: vi.fn() })); +vi.mock('@pagespace/lib/monitoring/activity-logger', () => ({ + setActivityBroadcastHook: vi.fn(), +})); +vi.mock('@/lib/websocket/socket-utils', () => ({ broadcastActivityEvent: vi.fn() })); +vi.mock('@pagespace/lib/observability/clickhouse-client', () => ({ + probeClickHouseStartup: vi.fn(() => ({ mode: 'disabled', reason: 'flag off' })), +})); +// Mocked so register()'s init import does not construct the real logger +// singleton (which would register real signal handlers + a flush timer on the +// test process). Its shutdown-owner behavior is covered by +// packages/lib graceful-shutdown.test.ts. +vi.mock('@pagespace/lib/logging/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { register } from '../instrumentation'; +import { probeClickHouseStartup } from '@pagespace/lib/observability/clickhouse-client'; + +describe('web instrumentation — ClickHouse composition-root wiring (#890 Phase 3 FIX)', () => { + let onSpy: MockInstance; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(probeClickHouseStartup).mockImplementation(() => ({ + mode: 'disabled', + reason: 'flag off', + })); + vi.stubEnv('NEXT_RUNTIME', 'nodejs'); + onSpy = vi.spyOn(process, 'on'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + onSpy.mockRestore(); + }); + + it('given nodejs startup, register() should probe the ClickHouse mode (fail-fast on misconfiguration)', async () => { + await register(); + + expect(probeClickHouseStartup).toHaveBeenCalled(); + }); + + it('given a misconfigured deploy, register() should REJECT so the server crashes at boot instead of silently dropping telemetry', async () => { + vi.mocked(probeClickHouseStartup).mockImplementation(() => { + throw new Error('ClickHouse misconfigured: startup probe failed'); + }); + + await expect(register()).rejects.toThrow('ClickHouse misconfigured'); + }); + + it('given nodejs startup, register() should NOT register a bespoke SIGTERM/SIGINT listener — graceful shutdown (flush → drain → exit) is owned by the shared logger, which register() initializes', async () => { + await register(); + + const signalListeners = onSpy.mock.calls.filter( + ([event]) => event === 'SIGTERM' || event === 'SIGINT', + ); + expect(signalListeners).toHaveLength(0); + }); + + it('given the edge runtime, register() should not touch the ClickHouse wiring', async () => { + vi.stubEnv('NEXT_RUNTIME', 'edge'); + + await register().catch(() => { + // edge branch imports sentry.edge.config which is unmocked; the only + // assertion that matters is that no CH wiring ran. + }); + + expect(probeClickHouseStartup).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/internal/monitoring/ingest/route.ts b/apps/web/src/app/api/internal/monitoring/ingest/route.ts index 0401c1cfd7..5810850034 100644 --- a/apps/web/src/app/api/internal/monitoring/ingest/route.ts +++ b/apps/web/src/app/api/internal/monitoring/ingest/route.ts @@ -3,6 +3,8 @@ import { createId } from '@paralleldrive/cuid2'; import { db } from '@pagespace/db/db' import { systemLogs } from '@pagespace/db/schema/monitoring'; import { writeApiMetrics, writeError } from '@pagespace/lib/logging/logger-database'; +import { isClickHouseEnabled } from '@pagespace/lib/observability/clickhouse-client'; +import { insertSystemLog } from '@pagespace/lib/observability/analytics-inserts'; import { loggers } from '@pagespace/lib/logging/logger-config'; import { secureCompare } from '@pagespace/lib/auth/secure-compare'; import { sanitizeIngestPayload, type IngestPayload } from '@/lib/monitoring/ingest-sanitizer'; @@ -55,6 +57,33 @@ export async function POST(request: Request) { const level = (statusCode >= 500 ? 'error' : statusCode >= 400 ? 'warn' : 'info') as SystemLogLevel; try { + // Shared shape for the request's system-log line; writeApiMetrics / + // writeError self-gate on CLICKHOUSE_ENABLED inside logger-database. + const systemLogEntry = { + id: createId(), + timestamp, + level, + message: + payload.message || `${method} ${payload.endpoint} ${statusCode} ${payload.duration}ms`, + category: 'api', + userId: payload.userId, + sessionId: payload.sessionId, + requestId: payload.requestId, + driveId: payload.driveId, + pageId: payload.pageId, + endpoint: payload.endpoint, + method, + ip: payload.ip, + userAgent: payload.userAgent, + duration: payload.duration, + metadata: { + ...payload.metadata, + statusCode, + requestSize: payload.requestSize, + responseSize: payload.responseSize, + }, + }; + const operations: Promise[] = [ writeApiMetrics({ endpoint: payload.endpoint, @@ -73,32 +102,14 @@ export async function POST(request: Request) { cacheKey: payload.cacheKey, timestamp, }), - db.insert(systemLogs).values({ - id: createId(), - timestamp, - level, - message: - payload.message || `${method} ${payload.endpoint} ${statusCode} ${payload.duration}ms`, - category: 'api', - userId: payload.userId, - sessionId: payload.sessionId, - requestId: payload.requestId, - driveId: payload.driveId, - pageId: payload.pageId, - endpoint: payload.endpoint, - method, - ip: payload.ip, - userAgent: payload.userAgent, - duration: payload.duration, - metadata: { - ...payload.metadata, - statusCode, - requestSize: payload.requestSize, - responseSize: payload.responseSize, - }, - }), ]; + if (isClickHouseEnabled()) { + insertSystemLog(systemLogEntry); + } else { + operations.push(db.insert(systemLogs).values(systemLogEntry)); + } + if (payload.error) { operations.push(writeError({ name: payload.errorName || 'RequestError', diff --git a/apps/web/src/instrumentation.ts b/apps/web/src/instrumentation.ts index bb5887aece..1212147d50 100644 --- a/apps/web/src/instrumentation.ts +++ b/apps/web/src/instrumentation.ts @@ -8,6 +8,27 @@ export async function register() { validateEnv(); console.log('[Instrumentation] Environment validation passed'); + // Fail-fast: a half-configured ClickHouse deploy (flag on, creds missing) + // must crash at boot — the insert adapters absorb per-row errors, so a + // running process would silently black out all 4 analytics tables + // (#890 Phase 3). Throws ClickHouseMisconfiguredError on 'misconfigured'. + const { probeClickHouseStartup } = await import('@pagespace/lib/observability/clickhouse-client'); + const chMode = probeClickHouseStartup(); + console.log(`[Instrumentation] ClickHouse analytics tier: ${chMode.mode}`); + + // Initialize the shared logger in this composition root. Constructing the + // singleton (module top-level `Logger.getInstance()`) installs its + // flush → drain → exit shutdown handler (logging/logger.ts → + // createShutdownHandler): on SIGTERM/SIGINT it flushes buffered logs, then + // drains the ClickHouse insert buffers (up to 500 rows/table — including + // rows buffered by direct adapter calls), then exits. It is the single, + // terminating owner of graceful shutdown; initializing it deterministically + // at boot means even an idle process that receives a signal before serving + // any request still drains its analytics buffers and terminates — rather + // than a bespoke drain-only listener that suppresses Node's default + // termination but never exits (#890 Phase 3). + await import('@pagespace/lib/logging/logger'); + const { setActivityBroadcastHook } = await import('@pagespace/lib/monitoring/activity-logger'); const { broadcastActivityEvent } = await import('@/lib/websocket/socket-utils'); setActivityBroadcastHook(broadcastActivityEvent); diff --git a/apps/web/src/lib/monitoring/__tests__/monitoring-queries-clickhouse-cutover.test.ts b/apps/web/src/lib/monitoring/__tests__/monitoring-queries-clickhouse-cutover.test.ts new file mode 100644 index 0000000000..2e4917b3ba --- /dev/null +++ b/apps/web/src/lib/monitoring/__tests__/monitoring-queries-clickhouse-cutover.test.ts @@ -0,0 +1,246 @@ +/** + * Flag-gated READ cutover for the web monitoring queries (#890 Phase 3 + * leaf 3): with CLICKHOUSE_ENABLED the readers over the 4 moved analytics + * tables query ClickHouse via the server-side lib readers; flag off, the PG + * path runs exactly as before. Web's getUserActivity reads userActivities + * (moved), so it converts its users JOIN to the two-step cross-store lookup. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const resultQueue = vi.hoisted(() => [] as unknown[][]); + +const makeChain = vi.hoisted(() => () => { + const rows = resultQueue.length ? resultQueue.shift()! : []; + const promise = Promise.resolve(rows); + const chain: Record = {}; + chain.from = vi.fn(() => chain); + chain.innerJoin = vi.fn(() => chain); + chain.leftJoin = vi.fn(() => chain); + chain.where = vi.fn(() => chain); + chain.groupBy = vi.fn(() => chain); + chain.orderBy = vi.fn(() => chain); + chain.having = vi.fn(() => chain); + chain.limit = vi.fn(() => promise); + chain.then = (resolve: (v: unknown[]) => void, reject?: (e: unknown) => void) => + promise.then(resolve, reject); + return chain; +}); + +const mockSelect = vi.hoisted(() => vi.fn(() => makeChain())); +const mockInArray = vi.hoisted(() => vi.fn((col: unknown, vals: unknown) => ({ type: 'inArray', col, vals }))); + +const mockIsClickHouseEnabled = vi.hoisted(() => vi.fn(() => false)); +const fakeClient = vi.hoisted(() => ({ __fake: 'clickhouse-client' })); +const chReads = vi.hoisted(() => ({ + getLogsByLevel: vi.fn(), + getErrorTrends: vi.fn(), + getFailedLogins: vi.fn(), + getRecentErrors: vi.fn(), + getErrorPatterns: vi.fn(), + getVolumeOverTime: vi.fn(), + getTopEndpoints: vi.fn(), + getRequestErrorCounts: vi.fn(), + getResponseTimes: vi.fn(), + getSlowQueries: vi.fn(), + getEndpointPerformance: vi.fn(), + getActivityHeatmap: vi.fn(), + getMostActiveUsers: vi.fn(), + getFeatureUsage: vi.fn(), + getActiveUserCount: vi.fn(), +})); + +vi.mock('@pagespace/lib/observability/clickhouse-client', () => ({ + isClickHouseEnabled: mockIsClickHouseEnabled, + getClickHouseClient: vi.fn(() => fakeClient), +})); + +vi.mock('@pagespace/lib/observability/analytics-reads', () => chReads); + +vi.mock('@pagespace/db/db', () => ({ db: { select: mockSelect } })); + +vi.mock('@pagespace/db/schema/monitoring', () => ({ + systemLogs: { timestamp: 'SL_TS', level: 'SL_LEVEL', category: 'SL_CATEGORY', ip: 'SL_IP', metadata: 'SL_META' }, + errorLogs: { id: 'EL_ID', timestamp: 'EL_TS', message: 'EL_MSG', name: 'EL_NAME', stack: 'EL_STACK', endpoint: 'EL_ENDPOINT', userId: 'EL_USER' }, + apiMetrics: { timestamp: 'AM_TS', endpoint: 'AM_ENDPOINT', duration: 'AM_DURATION', statusCode: 'AM_STATUS', userId: 'AM_USER' }, + userActivities: { userId: 'UA_USER', timestamp: 'UA_TS', action: 'UA_ACTION' }, + aiUsageLogs: { timestamp: 'AI_TS', provider: 'AI_PROVIDER', model: 'AI_MODEL', cost: 'AI_COST', totalTokens: 'AI_TOTAL', inputTokens: 'AI_IN', outputTokens: 'AI_OUT', success: 'AI_SUCCESS', userId: 'AI_USER', conversationId: 'AI_CONV', id: 'AI_ID', metadata: 'AI_META' }, +})); + +vi.mock('@pagespace/db/schema/auth', () => ({ + users: { id: 'U_ID', name: 'U_NAME', email: 'U_EMAIL' }, +})); + +vi.mock('@pagespace/db/schema/credits', () => ({ + creditLedger: { entryType: 'CL_ENTRY_TYPE', amountCents: 'CL_AMOUNT', chargeMillicents: 'CL_CHARGE', appliedCents: 'CL_APPLIED', realCostCents: 'CL_REAL_COST', aiUsageLogId: 'CL_AI_ID', createdAt: 'CL_CREATED', userId: 'CL_USER' }, + creditBalances: { userId: 'CB_USER', monthlyRemainingCents: 'CB_MONTHLY', topupRemainingCents: 'CB_TOPUP', debtCents: 'CB_DEBT' }, + creditHolds: { estCents: 'CH_EST', expiresAt: 'CH_EXPIRES' }, +})); + +vi.mock('@pagespace/db/schema/subscriptions', () => ({ + subscriptions: { status: 'SUB_STATUS', stripePriceId: 'SUB_PRICE_ID' }, +})); + +vi.mock('@pagespace/db/operators', () => ({ + sql: (() => { + const tag = vi.fn(() => 'SQL') as unknown as { (..._a: unknown[]): string; raw: (s: string) => string }; + tag.raw = ((s: string) => s) as (s: string) => string; + return tag; + })(), + eq: vi.fn((col: unknown, val: unknown) => ({ type: 'eq', col, val })), + gte: vi.fn((col: unknown, val: unknown) => ({ type: 'gte', col, val })), + lte: vi.fn((col: unknown, val: unknown) => ({ type: 'lte', col, val })), + and: vi.fn((...args: unknown[]) => ({ type: 'and', args })), + or: vi.fn((...args: unknown[]) => ({ type: 'or', args })), + gt: vi.fn((col: unknown, val: unknown) => ({ type: 'gt', col, val })), + asc: vi.fn((col: unknown) => col), + desc: vi.fn((col: unknown) => col), + count: vi.fn(() => 'COUNT'), + inArray: mockInArray, +})); + +vi.mock('@pagespace/lib/auth/user-repository', () => ({ + decryptUserDisplayFields: vi.fn(async (rows: unknown[]) => rows), +})); + +import { + getSystemHealth, + getApiMetrics, + getUserActivity, + getErrorAnalytics, + getPerformanceMetrics, +} from '../monitoring-queries'; + +const TS = new Date('2026-07-01T10:30:00.000Z'); + +beforeEach(() => { + vi.clearAllMocks(); + resultQueue.length = 0; + mockSelect.mockImplementation(() => makeChain()); + mockIsClickHouseEnabled.mockReturnValue(false); +}); + +describe('given CLICKHOUSE_ENABLED off', () => { + it('should never touch the ClickHouse readers', async () => { + await getSystemHealth(); + await getApiMetrics(); + await getUserActivity(); + await getErrorAnalytics(); + await getPerformanceMetrics(); + for (const fn of Object.values(chReads)) expect(fn).not.toHaveBeenCalled(); + expect(mockSelect).toHaveBeenCalled(); + }); +}); + +describe('given CLICKHOUSE_ENABLED on', () => { + beforeEach(() => { + mockIsClickHouseEnabled.mockReturnValue(true); + }); + + it('getSystemHealth should read logs, errors AND the active-user count from CH', async () => { + chReads.getLogsByLevel.mockResolvedValue([{ level: 'warn', count: 1 }]); + chReads.getRecentErrors.mockResolvedValue([ + { id: 'e1', timestamp: TS, message: 'boom', errorName: 'E', errorMessage: 'stack', endpoint: null, userId: null }, + ]); + chReads.getActiveUserCount.mockResolvedValue(3); + + const result = await getSystemHealth(TS); + + expect(chReads.getActiveUserCount).toHaveBeenCalledWith(fakeClient, expect.any(Date)); + expect(result.logsByLevel).toEqual([{ level: 'warn', count: 1 }]); + expect(result.recentErrors[0].errorMessage).toBe('stack'); + expect(result.activeUserCount).toBe(3); + expect(mockSelect).not.toHaveBeenCalled(); + }); + + it('getApiMetrics should aggregate in CH SQL', async () => { + chReads.getVolumeOverTime.mockResolvedValue([]); + chReads.getTopEndpoints.mockResolvedValue([]); + chReads.getRequestErrorCounts.mockResolvedValue({ total: 10, errors: 1 }); + + const result = await getApiMetrics(TS); + + expect(result.errorRate).toBe(10); + expect(result.totalRequests).toBe(10); + expect(mockSelect).not.toHaveBeenCalled(); + }); + + it('getUserActivity should fetch CH aggregates then look users up in PG (two-step, no SQL join)', async () => { + chReads.getActivityHeatmap.mockResolvedValue([ + { day_of_week: 3, hour_of_day: 10, activity_count: 2 }, + ]); + chReads.getMostActiveUsers.mockResolvedValue([ + { userId: 'u1', actionCount: 9 }, + { userId: 'ghost', actionCount: 5 }, // deleted user: PG lookup misses → dropped (inner-join parity) + { userId: 'u2', actionCount: 1 }, + ]); + chReads.getFeatureUsage.mockResolvedValue([{ action: 'create', count: 4 }]); + resultQueue.push([ + { id: 'u1', name: 'Alice' }, + { id: 'u2', name: 'Bob' }, + ]); + + const result = await getUserActivity(TS); + + expect(chReads.getMostActiveUsers).toHaveBeenCalledWith( + fakeClient, + { startDate: TS, endDate: undefined }, + 100, + ); + expect(mockInArray).toHaveBeenCalledWith('U_ID', ['u1', 'ghost', 'u2']); + expect(result.heatmapData).toEqual([{ day_of_week: 3, hour_of_day: 10, activity_count: 2 }]); + expect(result.mostActiveUsers).toEqual([ + { userId: 'u1', userName: 'Alice', actionCount: 9 }, + { userId: 'u2', userName: 'Bob', actionCount: 1 }, + ]); + expect(result.featureUsage).toEqual([{ action: 'create', count: 4 }]); + }); + + it('getUserActivity should skip the PG user lookup entirely when CH returns no activity', async () => { + chReads.getActivityHeatmap.mockResolvedValue([]); + chReads.getMostActiveUsers.mockResolvedValue([]); + chReads.getFeatureUsage.mockResolvedValue([]); + + const result = await getUserActivity(); + + expect(result.mostActiveUsers).toEqual([]); + expect(mockSelect).not.toHaveBeenCalled(); + }); + + it('getErrorAnalytics should keep the PG output massaging (web passes metadata through unmasked)', async () => { + chReads.getErrorTrends.mockResolvedValue([{ hour: TS, category: 'api', count: 2 }]); + chReads.getErrorPatterns.mockResolvedValue([{ name: 'E', endpoint: '/x', count: 1 }]); + chReads.getFailedLogins.mockResolvedValue([ + { timestamp: TS, ip: null, metadata: { reason: 'bad password' } }, + ]); + + const result = await getErrorAnalytics(TS); + + expect(result.errorTrends).toEqual([{ hour: TS, category: 'api', count: '2' }]); + expect(result.errorPatterns).toEqual([{ name: 'E', category: '/x', count: 1 }]); + expect(result.failedLogins).toEqual([ + { timestamp: TS, ip: null, metadata: { reason: 'bad password' } }, + ]); + expect(mockSelect).not.toHaveBeenCalled(); + }); + + it('getPerformanceMetrics should read response times, slow queries and per-endpoint stats from CH', async () => { + chReads.getResponseTimes.mockResolvedValue([ + { hour: TS, avg_response_time: 5, max_response_time: 9, min_response_time: 1 }, + ]); + chReads.getSlowQueries.mockResolvedValue([ + { endpoint: '/slow', responseTime: 6000, timestamp: TS, userId: null }, + ]); + chReads.getEndpointPerformance.mockResolvedValue([{ metric: '/x', avgValue: 5, count: 2 }]); + + const result = await getPerformanceMetrics(TS); + + expect(result.responseTimes).toEqual([ + { hour: TS, avg_response_time: 5, max_response_time: 9, min_response_time: 1 }, + ]); + expect(result.slowQueries).toEqual([ + { endpoint: '/slow', responseTime: 6000, timestamp: TS, userId: null }, + ]); + expect(result.metricTypes).toEqual([{ metric: '/x', avgValue: 5, count: 2 }]); + expect(mockSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/monitoring/__tests__/monitoring-queries.parity.integration.test.ts b/apps/web/src/lib/monitoring/__tests__/monitoring-queries.parity.integration.test.ts new file mode 100644 index 0000000000..8c90ffdee7 --- /dev/null +++ b/apps/web/src/lib/monitoring/__tests__/monitoring-queries.parity.integration.test.ts @@ -0,0 +1,389 @@ +/** + * CH-vs-PG parity for the re-pointed web monitoring readers (#890 Phase 3 + * leaf 3) — the acceptance backbone for the read cutover. + * + * Both stores are seeded with the SAME logical rows inside a random past + * time window unique to this run (the readers aggregate globally, so a + * window nobody else writes into makes the windowed queries deterministic + * even against a dirty dev database). Each re-pointed reader then runs + * twice — flag off (PG path, exactly as before this leaf) and flag on (CH + * path) — and the outputs are compared after normalization (Date→ms, + * numeric strings→numbers, order-insensitive sorts, float tolerance). + * + * The 15-minute active-user count is now-relative and cannot be windowed, + * so it is asserted as equal DELTAS after seeding fresh distinct users into + * both stores. + * + * Requires DATABASE_URL → migrated Postgres (scripts/test-with-db.sh) AND + * the dev ClickHouse container (docker compose -f docker-compose.yml -f + * docker-compose.dev.yml up -d clickhouse). Skips itself when either store + * is unreachable. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { get } from 'node:http'; +import { + getClickHouseClient, + type ClickHouseClient, +} from '@pagespace/lib/observability/clickhouse-client'; +import { db } from '@pagespace/db/db'; +import { inArray, like } from '@pagespace/db/operators'; +import { users } from '@pagespace/db/schema/auth'; +import { + apiMetrics, + systemLogs, + userActivities, + errorLogs, +} from '@pagespace/db/schema/monitoring'; +import { factories } from '@pagespace/db/test/factories'; +import { ensureAnalyticsTables } from '@pagespace/lib/observability/clickhouse-ddl'; +import { + mapApiMetricToRow, + mapSystemLogToRow, + mapUserActivityToRow, + mapErrorLogToRow, +} from '@pagespace/lib/observability/analytics-rows'; +import { + getSystemHealth, + getApiMetrics, + getUserActivity, + getErrorAnalytics, + getPerformanceMetrics, +} from '../monitoring-queries'; + +// PG EXTRACT(HOUR/DOW) buckets in the server's local zone while CH buckets in +// UTC; production runs UTC, so the parity contract (and this test) is UTC. +process.env.TZ = 'UTC'; + +const CH_URL = process.env.CLICKHOUSE_URL ?? 'http://localhost:8123'; +const CH_ENV = { + CLICKHOUSE_ENABLED: 'true', + CLICKHOUSE_URL: CH_URL, + CLICKHOUSE_USER: process.env.CLICKHOUSE_USER ?? 'user', + CLICKHOUSE_PASSWORD: process.env.CLICKHOUSE_PASSWORD ?? 'password', + CLICKHOUSE_DATABASE: process.env.CLICKHOUSE_DATABASE ?? 'pagespace_analytics', +}; + +// node:http, not fetch — the web test setup stubs the global fetch. +const chReachable = await new Promise((resolve) => { + const req = get(`${CH_URL}/ping`, { timeout: 2000 }, (res) => { + res.resume(); + resolve(res.statusCode === 200); + }); + req.on('timeout', () => req.destroy()); + req.on('error', () => resolve(false)); +}); + +const pgReachable = await (async () => { + try { + await db.select({ id: errorLogs.id }).from(errorLogs).limit(1); + return true; + } catch { + return false; + } +})(); + +// Random PAST hour-aligned window 36–72h ago. It must be RECENT: the CH +// tables carry TTLs (system_logs 30d is the tightest) and ClickHouse drops +// already-expired rows at insert time (optimize_on_insert), so seeding into +// a deep-past window silently inserts nothing. The random 36h offset keeps +// concurrent/successive runs from landing in each other's window. +const WINDOW_START = new Date( + Math.floor((Date.now() - 72 * 3_600_000) / 3_600_000) * 3_600_000 + + Math.floor(Math.random() * 36) * 3_600_000, +); +const WINDOW_END = new Date(WINDOW_START.getTime() + 2 * 3_600_000); +const runId = `parity-${WINDOW_START.getTime()}`; +const at = (minutes: number): Date => new Date(WINDOW_START.getTime() + minutes * 60_000); + +/** Run fn with the CH read path enabled, restoring the previous env after. */ +async function withClickHouse(fn: () => Promise): Promise { + const previous: Record = {}; + for (const [key, value] of Object.entries(CH_ENV)) { + previous[key] = process.env[key]; + process.env[key] = value; + } + try { + return await fn(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +// ── Normalizers (PG returns Dates + numeric strings; CH returns Dates + numbers) ── +const ms = (v: unknown): number => (v instanceof Date ? v.getTime() : new Date(String(v)).getTime()); +const num = (v: unknown): number | null => (v === null || v === undefined ? null : Math.round(Number(v) * 1e6) / 1e6); +const byKey = (key: (r: T) => string) => (a: T, b: T) => key(a).localeCompare(key(b)); + +let seededUsers: { u1: string; u2: string; ghost: string }; +let chClient: ClickHouseClient; +const createdUserIds: string[] = []; + +describe.skipIf(!chReachable || !pgReachable)( + 'web monitoring readers: ClickHouse path ≡ Postgres path on identically seeded data', + () => { + beforeAll(async () => { + // Construct the process singleton through the lib accessor — the exact + // client instance the re-pointed readers will use (apps/web declares no + // direct @clickhouse/client dependency; server code reaches CH only via + // @pagespace/lib). + chClient = (await withClickHouse(async () => getClickHouseClient()))!; + await ensureAnalyticsTables(chClient); + + const u1 = await factories.createUser(); + const u2 = await factories.createUser(); + createdUserIds.push(u1.id, u2.id); + seededUsers = { u1: u1.id, u2: u2.id, ghost: `${runId}-ghost` }; + + // ── the same logical rows into BOTH stores ───────────────────────────── + const metricRows = [ + { id: `${runId}-m1`, timestamp: at(5), endpoint: '/api/a', method: 'GET', statusCode: 200, duration: 100 }, + { id: `${runId}-m2`, timestamp: at(10), endpoint: '/api/a', method: 'GET', statusCode: 404, duration: 200 }, + { id: `${runId}-m3`, timestamp: at(65), endpoint: '/api/b', method: 'POST', statusCode: 200, duration: 300 }, + { id: `${runId}-m4`, timestamp: at(70), endpoint: '/api/b', method: 'POST', statusCode: 500, duration: 6000, userId: u1.id }, + { id: `${runId}-m5`, timestamp: at(75), endpoint: '/api/a', method: 'GET', statusCode: 200, duration: 150 }, + ] as const; + + const logRows = [ + { id: `${runId}-s1`, timestamp: at(6), level: 'info', message: 'ok', category: 'api' }, + { id: `${runId}-s2`, timestamp: at(12), level: 'error', message: 'api blew up', category: 'api' }, + { id: `${runId}-s3`, timestamp: at(66), level: 'error', message: 'uncategorized', category: undefined }, + { id: `${runId}-s4`, timestamp: at(68), level: 'warn', message: 'login failed', category: 'auth', ip: '9.9.9.9', metadata: { detail: 'login failed for jane@example.com' } }, + { id: `${runId}-s5`, timestamp: at(69), level: 'error', message: 'lockout', category: 'auth', metadata: { attempts: 5 } }, + ] as const; + + const activityRows = [ + { id: `${runId}-a1`, timestamp: at(7), userId: u1.id, action: 'create' }, + { id: `${runId}-a2`, timestamp: at(8), userId: u1.id, action: 'create' }, + { id: `${runId}-a3`, timestamp: at(9), userId: u1.id, action: 'update' }, + { id: `${runId}-a4`, timestamp: at(64), userId: u2.id, action: 'update' }, + // Deleted user: activities survive, users row does not — both paths must drop + // it from mostActiveUsers but keep it in the heatmap/feature aggregates. + { id: `${runId}-a5`, timestamp: at(11), userId: seededUsers.ghost, action: 'delete' }, + { id: `${runId}-a6`, timestamp: at(13), userId: seededUsers.ghost, action: 'delete' }, + ] as const; + + const errorRows = [ + { id: `${runId}-e1`, timestamp: at(15), name: 'TypeError', message: 'boom', stack: 'trace1', endpoint: '/api/a', userId: u1.id }, + { id: `${runId}-e2`, timestamp: at(20), name: 'TypeError', message: 'bang', stack: undefined, endpoint: '/api/a' }, + { id: `${runId}-e3`, timestamp: at(67), name: 'RangeError', message: 'oops', stack: 'trace3', endpoint: undefined }, + ] as const; + + await db.insert(apiMetrics).values(metricRows.map((r) => ({ ...r, method: r.method as 'GET' | 'POST' }))); + await db.insert(systemLogs).values(logRows.map((r) => ({ ...r, level: r.level as 'info' | 'warn' | 'error', category: r.category ?? null, metadata: 'metadata' in r ? r.metadata : null }))); + await db.insert(userActivities).values([...activityRows]); + await db.insert(errorLogs).values(errorRows.map((r) => ({ ...r, stack: r.stack ?? null, endpoint: r.endpoint ?? null }))); + + const identity = (row: { id: string; timestamp: Date }) => ({ id: row.id, timestamp: row.timestamp }); + await chClient.insert({ table: 'api_metrics', values: metricRows.map((r) => mapApiMetricToRow(r, identity(r))), format: 'JSONEachRow' }); + await chClient.insert({ table: 'system_logs', values: logRows.map((r) => mapSystemLogToRow(r, identity(r))), format: 'JSONEachRow' }); + await chClient.insert({ table: 'user_activities', values: activityRows.map((r) => mapUserActivityToRow(r, identity(r))), format: 'JSONEachRow' }); + await chClient.insert({ table: 'error_logs', values: errorRows.map((r) => mapErrorLogToRow(r, identity(r))), format: 'JSONEachRow' }); + }, 30_000); + + afterAll(async () => { + delete process.env.CLICKHOUSE_ENABLED; + try { + await db.delete(apiMetrics).where(like(apiMetrics.id, `${runId}%`)); + await db.delete(systemLogs).where(like(systemLogs.id, `${runId}%`)); + await db.delete(userActivities).where(like(userActivities.id, `${runId}%`)); + await db.delete(errorLogs).where(like(errorLogs.id, `${runId}%`)); + if (createdUserIds.length) { + await db.delete(userActivities).where(inArray(userActivities.userId, createdUserIds)); + await db.delete(users).where(inArray(users.id, createdUserIds)); + } + } catch { + // best-effort cleanup + } + try { + for (const table of ['api_metrics', 'system_logs', 'user_activities', 'error_logs']) { + await chClient.command({ query: `DELETE FROM ${table} WHERE id LIKE '${runId}%'` }); + } + } catch { + // lightweight deletes are best-effort; rows sit in a unique past window + } + await chClient?.close(); + }, 30_000); + + it('getSystemHealth: logsByLevel and recentErrors match', async () => { + const pg = await getSystemHealth(WINDOW_START, WINDOW_END); + const ch = await withClickHouse(() => getSystemHealth(WINDOW_START, WINDOW_END)); + + const levels = (r: typeof pg) => + r.logsByLevel.map((l) => ({ level: l.level, count: Number(l.count) })).sort(byKey((l) => l.level)); + expect(levels(ch)).toEqual(levels(pg)); + expect(levels(pg)).toEqual([ + { level: 'error', count: 3 }, + { level: 'info', count: 1 }, + { level: 'warn', count: 1 }, + ]); + + const errors = (r: typeof pg) => + r.recentErrors.map((e) => ({ + id: e.id, + timestamp: ms(e.timestamp), + message: e.message, + errorName: e.errorName, + errorMessage: e.errorMessage, + endpoint: e.endpoint, + userId: e.userId, + })); + expect(errors(ch)).toEqual(errors(pg)); + expect(errors(pg).map((e) => e.id)).toEqual([`${runId}-e3`, `${runId}-e2`, `${runId}-e1`]); + // stack-null row falls back to message in both paths + expect(errors(pg)[1].errorMessage).toBe('bang'); + }); + + it('getSystemHealth: the 15-minute active-user count moves by the same delta in both stores', async () => { + const freshA = `${runId}-fresh-a`; + const freshB = `${runId}-fresh-b`; + const now = new Date(); + + const pgBefore = (await getSystemHealth(WINDOW_START, WINDOW_END)).activeUserCount; + await db.insert(userActivities).values([ + { id: `${runId}-recent-1`, timestamp: now, userId: freshA, action: 'parity_probe' }, + { id: `${runId}-recent-2`, timestamp: now, userId: freshB, action: 'parity_probe' }, + ]); + const pgAfter = (await getSystemHealth(WINDOW_START, WINDOW_END)).activeUserCount; + expect(pgAfter - pgBefore).toBe(2); + + const chBefore = await withClickHouse(async () => (await getSystemHealth(WINDOW_START, WINDOW_END)).activeUserCount); + await chClient.insert({ + table: 'user_activities', + values: [ + mapUserActivityToRow({ userId: freshA, action: 'parity_probe' }, { id: `${runId}-recent-1`, timestamp: now }), + mapUserActivityToRow({ userId: freshB, action: 'parity_probe' }, { id: `${runId}-recent-2`, timestamp: now }), + ], + format: 'JSONEachRow', + }); + const chAfter = await withClickHouse(async () => (await getSystemHealth(WINDOW_START, WINDOW_END)).activeUserCount); + expect(chAfter - chBefore).toBe(2); + }); + + it('getApiMetrics: volume, top endpoints, error rate and totals match', async () => { + const pg = await getApiMetrics(WINDOW_START, WINDOW_END); + const ch = await withClickHouse(() => getApiMetrics(WINDOW_START, WINDOW_END)); + + const volume = (r: typeof pg) => + r.volumeOverTime + .map((v) => ({ hour: ms(v.hour), count: Number(v.count), avg: num(v.avg_response_time) })) + .sort((a, b) => a.hour - b.hour); + expect(volume(ch)).toEqual(volume(pg)); + expect(volume(pg).map((v) => v.count)).toEqual([2, 3]); + + const top = (r: typeof pg) => + r.topEndpoints + .map((t) => ({ endpoint: t.endpoint, count: Number(t.count), avg: num(t.avgResponseTime) })) + .sort(byKey((t) => t.endpoint)); + expect(top(ch)).toEqual(top(pg)); + expect(top(pg)).toEqual([ + { endpoint: '/api/a', count: 3, avg: 150 }, + { endpoint: '/api/b', count: 2, avg: 3150 }, + ]); + + expect(ch.totalRequests).toBe(pg.totalRequests); + expect(pg.totalRequests).toBe(5); + expect(num(ch.errorRate)).toEqual(num(pg.errorRate)); + expect(pg.errorRate).toBe(40); + }); + + it('getUserActivity: heatmap, most-active (two-step join) and feature usage match', async () => { + const pg = await getUserActivity(WINDOW_START, WINDOW_END); + const ch = await withClickHouse(() => getUserActivity(WINDOW_START, WINDOW_END)); + + const heatmap = (r: typeof pg) => + r.heatmapData + .map((h) => ({ dow: Number(h.day_of_week), hour: Number(h.hour_of_day), count: Number(h.activity_count) })) + .sort(byKey((h) => `${h.dow}-${h.hour}`)); + expect(heatmap(ch)).toEqual(heatmap(pg)); + expect(heatmap(pg).reduce((sum, h) => sum + h.count, 0)).toBe(6); // ghost rows included + + const active = (r: typeof pg) => + r.mostActiveUsers.map((u) => ({ userId: u.userId, userName: u.userName, actionCount: Number(u.actionCount) })); + expect(active(ch)).toEqual(active(pg)); + // the users INNER JOIN (PG) and the two-step lookup (CH) both drop the ghost + expect(active(pg).map((u) => u.userId)).toEqual([seededUsers.u1, seededUsers.u2]); + expect(active(pg).map((u) => u.actionCount)).toEqual([3, 1]); + + const usage = (r: typeof pg) => + r.featureUsage.map((f) => ({ action: f.action, count: Number(f.count) })).sort(byKey((f) => f.action)); + expect(usage(ch)).toEqual(usage(pg)); + expect(usage(pg)).toEqual([ + { action: 'create', count: 2 }, + { action: 'delete', count: 2 }, + { action: 'update', count: 2 }, + ]); + }); + + it('getErrorAnalytics: trends (null→other), patterns and failed logins match', async () => { + const pg = await getErrorAnalytics(WINDOW_START, WINDOW_END); + const ch = await withClickHouse(() => getErrorAnalytics(WINDOW_START, WINDOW_END)); + + const trends = (r: typeof pg) => + r.errorTrends + .map((t) => ({ hour: ms(t.hour), category: t.category, count: t.count })) + .sort(byKey((t) => `${t.hour}-${t.category}`)); + expect(trends(ch)).toEqual(trends(pg)); + expect(trends(pg).map((t) => t.category).sort()).toEqual(['api', 'auth', 'other']); + + const patterns = (r: typeof pg) => + r.errorPatterns + .map((p) => ({ name: p.name, category: p.category, count: Number(p.count) })) + .sort(byKey((p) => `${p.name}-${p.category}`)); + expect(patterns(ch)).toEqual(patterns(pg)); + expect(patterns(pg)).toEqual([ + { name: 'RangeError', category: 'general', count: 1 }, + { name: 'TypeError', category: '/api/a', count: 2 }, + ]); + + const logins = (r: typeof pg) => + r.failedLogins.map((l) => ({ timestamp: ms(l.timestamp), ip: l.ip, metadata: l.metadata })); + expect(logins(ch)).toEqual(logins(pg)); + expect(logins(pg).map((l) => l.ip)).toEqual([null, '9.9.9.9']); + }); + + it('getPerformanceMetrics: response times, slow queries and per-endpoint stats match', async () => { + const pg = await getPerformanceMetrics(WINDOW_START, WINDOW_END); + const ch = await withClickHouse(() => getPerformanceMetrics(WINDOW_START, WINDOW_END)); + + const times = (r: typeof pg) => + r.responseTimes + .map((t) => ({ + hour: ms(t.hour), + avg: num(t.avg_response_time), + max: num(t.max_response_time), + min: num(t.min_response_time), + })) + .sort((a, b) => a.hour - b.hour); + expect(times(ch)).toEqual(times(pg)); + expect(times(pg)).toEqual([ + { hour: WINDOW_START.getTime(), avg: 150, max: 200, min: 100 }, + { hour: WINDOW_START.getTime() + 3_600_000, avg: 2150, max: 6000, min: 150 }, + ]); + + const slow = (r: typeof pg) => + r.slowQueries.map((s) => ({ + endpoint: s.endpoint, + responseTime: Number(s.responseTime), + timestamp: ms(s.timestamp), + userId: s.userId, + })); + expect(slow(ch)).toEqual(slow(pg)); + expect(slow(pg)).toEqual([ + { endpoint: '/api/b', responseTime: 6000, timestamp: at(70).getTime(), userId: seededUsers.u1 }, + ]); + + const perEndpoint = (r: typeof pg) => + r.metricTypes + .map((m) => ({ metric: m.metric, avg: num(m.avgValue), count: Number(m.count) })) + .sort(byKey((m) => m.metric)); + expect(perEndpoint(ch)).toEqual(perEndpoint(pg)); + expect(perEndpoint(pg)).toEqual([ + { metric: '/api/a', avg: 150, count: 3 }, + { metric: '/api/b', avg: 3150, count: 2 }, + ]); + }); + }, +); diff --git a/apps/web/src/lib/monitoring/monitoring-queries.ts b/apps/web/src/lib/monitoring/monitoring-queries.ts index eaa926d5ba..bd576d78e5 100644 --- a/apps/web/src/lib/monitoring/monitoring-queries.ts +++ b/apps/web/src/lib/monitoring/monitoring-queries.ts @@ -15,11 +15,72 @@ import { computeBalanceDrift, isNegativeMargin } from '@pagespace/lib/billing/cr import { BALANCE_DRIFT_TOLERANCE_CENTS, NEGATIVE_MARGIN_FLOOR_BPS } from '@pagespace/lib/billing/credit-pricing'; import { getTierFromPrice } from '@/lib/stripe/price-config'; import type { SubscriptionTier } from '@/lib/subscription/plans'; +import { isClickHouseEnabled, getClickHouseClient } from '@pagespace/lib/observability/clickhouse-client'; +import { + getLogsByLevel as chGetLogsByLevel, + getRecentErrors as chGetRecentErrors, + getVolumeOverTime as chGetVolumeOverTime, + getTopEndpoints as chGetTopEndpoints, + getRequestErrorCounts as chGetRequestErrorCounts, + getErrorTrends as chGetErrorTrends, + getErrorPatterns as chGetErrorPatterns, + getFailedLogins as chGetFailedLogins, + getActivityHeatmap as chGetActivityHeatmap, + getMostActiveUsers as chGetMostActiveUsers, + getFeatureUsage as chGetFeatureUsage, + getActiveUserCount as chGetActiveUserCount, + getResponseTimes as chGetResponseTimes, + getSlowQueries as chGetSlowQueries, + getEndpointPerformance as chGetEndpointPerformance, +} from '@pagespace/lib/observability/analytics-reads'; +import type { ClickHouseClient } from '@pagespace/lib/observability/clickhouse-client'; + +/** + * Post-cutover (#890 Phase 3) new analytics rows land only in ClickHouse, so + * the readers over the 4 moved tables (apiMetrics, systemLogs, + * userActivities, errorLogs) query CH when the flag is on — server-side + * only (never from a browser bundle; next.config aliases '@clickhouse/client' + * to false for browser builds), aggregations in CH SQL — and hit PG exactly + * as before when the flag is off. Returns null when the tier is off so + * callers fall through to the PG path. Anything that JOINed the moved tables + * to users converts to a two-step lookup (fetch CH rows → fetch users by id + * from PG → merge in app code); CH and PG are never SQL-joined. + */ +function clickHouseClientIfEnabled(): ClickHouseClient | null { + return isClickHouseEnabled() ? getClickHouseClient() : null; +} + +/** + * Over-fetch factor for the two-step most-active-users lookup: activity rows + * whose user has since been deleted have no users row — the PG path's INNER + * JOIN silently drops them, so the CH path fetches extra ranks and drops + * misses after the lookup to keep the top-10 comparable. + */ +const MOST_ACTIVE_USERS_OVERFETCH = 100; /** * Get system health overview */ export async function getSystemHealth(startDate?: Date, endDate?: Date) { + const chClient = clickHouseClientIfEnabled(); + if (chClient) { + const window = { startDate, endDate }; + const fifteenMinutesAgo = new Date(Date.now() - 15 * 60 * 1000); + const [logsByLevel, recentErrors, activeUserCount] = await Promise.all([ + chGetLogsByLevel(chClient, window), + chGetRecentErrors(chClient, window, 20), + chGetActiveUserCount(chClient, fifteenMinutesAgo), + ]); + return { + logsByLevel, + recentErrors: recentErrors.map((entry) => ({ + ...entry, + errorMessage: entry.errorMessage || entry.message, + })), + activeUserCount, + }; + } + const logConditions: SQL[] = []; if (startDate) { @@ -83,8 +144,24 @@ export async function getSystemHealth(startDate?: Date, endDate?: Date) { * Get API metrics */ export async function getApiMetrics(startDate?: Date, endDate?: Date) { + const chClient = clickHouseClientIfEnabled(); + if (chClient) { + const window = { startDate, endDate }; + const [volumeOverTime, topEndpoints, requestCounts] = await Promise.all([ + chGetVolumeOverTime(chClient, window), + chGetTopEndpoints(chClient, window), + chGetRequestErrorCounts(chClient, window), + ]); + return { + volumeOverTime, + topEndpoints, + errorRate: requestCounts.total > 0 ? (requestCounts.errors / requestCounts.total) * 100 : 0, + totalRequests: requestCounts.total, + }; + } + const conditions = []; - + if (startDate) { conditions.push(gte(apiMetrics.timestamp, startDate)); } @@ -148,8 +225,42 @@ export async function getApiMetrics(startDate?: Date, endDate?: Date) { * Get user activity data */ export async function getUserActivity(startDate?: Date, endDate?: Date) { + const chClient = clickHouseClientIfEnabled(); + if (chClient) { + const window = { startDate, endDate }; + const [heatmapData, activeUserCounts, featureUsage] = await Promise.all([ + chGetActivityHeatmap(chClient, window), + chGetMostActiveUsers(chClient, window, MOST_ACTIVE_USERS_OVERFETCH), + chGetFeatureUsage(chClient, window), + ]); + + // Two-step cross-store join: user names come from main PG by id. + const userIds = activeUserCounts.map((row) => row.userId); + const userRows = userIds.length > 0 + ? await db + .select({ id: users.id, name: users.name }) + .from(users) + .where(inArray(users.id, userIds)) + : []; + const nameById = new Map(userRows.map((u) => [u.id, u.name])); + const mostActiveUsers = activeUserCounts + .filter((row) => nameById.has(row.userId)) + .slice(0, 10) + .map((row) => ({ + userId: row.userId, + userName: nameById.get(row.userId) ?? null, + actionCount: row.actionCount, + })); + + return { + heatmapData, + mostActiveUsers: await decryptUserDisplayFields(mostActiveUsers), + featureUsage, + }; + } + const conditions = []; - + if (startDate) { conditions.push(gte(userActivities.timestamp, startDate)); } @@ -300,6 +411,33 @@ export async function getAiUsageMetrics(startDate?: Date, endDate?: Date) { * Get error analytics */ export async function getErrorAnalytics(startDate?: Date, endDate?: Date) { + const chClient = clickHouseClientIfEnabled(); + if (chClient) { + const window = { startDate, endDate }; + const [errorTrendsRaw, errorPatternsRaw, failedLogins] = await Promise.all([ + chGetErrorTrends(chClient, window), + chGetErrorPatterns(chClient, window), + chGetFailedLogins(chClient, window), + ]); + return { + errorTrends: errorTrendsRaw.map((item) => ({ + hour: item.hour, + category: item.category ?? 'other', + count: item.count.toString(), + })), + errorPatterns: errorPatternsRaw.map((pattern) => ({ + name: pattern.name ?? 'Unknown Error', + category: pattern.endpoint ?? 'general', + count: pattern.count, + })), + failedLogins: failedLogins.map((login) => ({ + timestamp: login.timestamp, + ip: login.ip, + metadata: login.metadata, + })), + }; + } + const errorLevelConditions: SQL[] = [eq(systemLogs.level, 'error' as const)]; if (startDate) { @@ -391,8 +529,19 @@ export async function getErrorAnalytics(startDate?: Date, endDate?: Date) { * Get performance metrics */ export async function getPerformanceMetrics(startDate?: Date, endDate?: Date) { + const chClient = clickHouseClientIfEnabled(); + if (chClient) { + const window = { startDate, endDate }; + const [responseTimes, slowQueries, metricTypes] = await Promise.all([ + chGetResponseTimes(chClient, window), + chGetSlowQueries(chClient, window), + chGetEndpointPerformance(chClient, window), + ]); + return { responseTimes, slowQueries, metricTypes }; + } + const conditions = []; - + if (startDate) { conditions.push(gte(apiMetrics.timestamp, startDate)); } diff --git a/apps/web/src/lib/repositories/conversation-repository.ts b/apps/web/src/lib/repositories/conversation-repository.ts index f94781a0a8..fdb26f1124 100644 --- a/apps/web/src/lib/repositories/conversation-repository.ts +++ b/apps/web/src/lib/repositories/conversation-repository.ts @@ -9,6 +9,8 @@ import { eq, and, sql } from '@pagespace/db/operators' import { chatMessages, pages } from '@pagespace/db/schema/core' import { userActivities } from '@pagespace/db/schema/monitoring' import { conversations } from '@pagespace/db/schema/conversations'; +import { isClickHouseEnabled } from '@pagespace/lib/observability/clickhouse-client'; +import { insertUserActivity } from '@pagespace/lib/observability/analytics-inserts'; import { invalidate as invalidateCompaction } from '@/lib/ai/core/compaction/compaction-repository'; // Types for repository operations @@ -367,7 +369,7 @@ export const conversationRepository = { * Log conversation deletion for audit trail */ async logConversationDeletion(data: ConversationDeletionLog): Promise { - await db.insert(userActivities).values({ + const activity = { userId: data.userId, action: 'delete', resource: 'conversation', @@ -381,7 +383,15 @@ export const conversationRepository = { lastMessageTime: data.metadata?.lastMessageTime, deletionReason: 'user_initiated', }, - }); + }; + + // #890 Phase 3: user_activities cuts over to ClickHouse when enabled. + if (isClickHouseEnabled()) { + insertUserActivity(activity); + return; + } + + await db.insert(userActivities).values(activity); }, }; diff --git a/bun.lock b/bun.lock index b01faabb20..a91fea4138 100644 --- a/bun.lock +++ b/bun.lock @@ -497,6 +497,7 @@ "version": "0.1.0", "dependencies": { "@aws-sdk/client-s3": "^3.1045.0", + "@clickhouse/client": "^1.23.1", "@fly/sprites": "0.0.1-rc37", "@iarna/toml": "^2.2.5", "@pagespace/db": "workspace:*", @@ -780,6 +781,8 @@ "@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="], + "@clickhouse/client": ["@clickhouse/client@1.23.1", "", {}, "sha512-vs3/Zc1dHvT171btW5nMoPsPCJ6QVJ5pp7obxzO5sjqwFx/jjz9wwCAqcFOdc2DhprugDBaVn+4dVY8hG3A9nw=="], + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a06a1bbd72..fab2afae7a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -18,3 +18,8 @@ services: networks: - internal - frontend + + clickhouse: + networks: + - internal + - frontend diff --git a/docker-compose.yml b/docker-compose.yml index 60512c18b1..d2aaeb3f37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,6 +53,33 @@ services: limits: memory: 200M + # Analytics tier (#890 Phase 3): local ClickHouse so dev + integration tests + # have a real CH to hit. DEV/CI ONLY — in production the analytics tier is + # cloud-managed ClickHouse Cloud (AWS us-east-1), so the tenant/onprem + # compose stacks deliberately do NOT carry this service. Ships dark: no app + # service sets CLICKHOUSE_ENABLED; devs opt in via .env (see .env.example). + clickhouse: + image: clickhouse/clickhouse-server:25.3-alpine + ports: + - "8123:8123" + volumes: + - clickhouse_data:/var/lib/clickhouse + environment: + - CLICKHOUSE_DB=pagespace_analytics + - CLICKHOUSE_USER=user + - CLICKHOUSE_PASSWORD=password + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - internal + deploy: + resources: + limits: + memory: 1G + migrate: build: context: . @@ -327,4 +354,5 @@ networks: volumes: postgres_data: postgres_admin_data: + clickhouse_data: cron_logs: diff --git a/docs/security/audit-log-retention-policy.md b/docs/security/audit-log-retention-policy.md index 5572079df6..bd83c17df6 100644 --- a/docs/security/audit-log-retention-policy.md +++ b/docs/security/audit-log-retention-policy.md @@ -7,19 +7,44 @@ configurable retention windows for monitoring data. | Table | Retention | Mechanism | |-------|-----------|-----------| -| `security_audit_log` | **Infinite** | Never deleted — tamper-evident hash chain requires an uninterrupted chain (GDPR Art 17(3)(b) legal-obligation justification). | +| `security_audit_log` (Admin PG) | **Infinite** | Never deleted — tamper-evident hash chain requires an uninterrupted chain (GDPR Art 17(3)(b) legal-obligation justification). Monthly partitions exist for index/vacuum bounding only; `admin_ensure_partitions` is create-ahead ONLY, no drop path exists (`drizzle-admin/0002`, pinned by `admin-partition-migration.test.ts`). | +| `siem_delivery_receipts` (Admin PG) | **Infinite** | Never deleted — receipts are the external-witness half of the anchoring evidence (paired with S3 Object-Lock anchors); dropping them would truncate the proof trail the infinite-retention chain relies on. Same create-ahead-only partition maintenance as above, deliberately without a drop path. | | `activity_logs` | **Infinite (rows), tiered visibility** | Never deleted (hash chain + rollback provenance). Old rows are flagged `isArchived = true` to keep the hot/active view bounded — see **Archival** below. | -| `api_metrics` | `RETENTION_API_METRICS_DAYS` (default 90) | Time-based delete. | -| `system_logs` | `RETENTION_SYSTEM_LOGS_DAYS` (default 30) | Time-based delete. | -| `error_logs` | `RETENTION_ERROR_LOGS_DAYS` (default 90) | Time-based delete. | -| `user_activities` | `RETENTION_USER_ACTIVITIES_DAYS` (default 180) | Time-based delete. | -| `ai_usage_logs` | `RETENTION_AI_USAGE_LOGS_DAYS` (default 90) | Time-based delete (purge-ai-usage-logs cron). | +| `api_metrics` | `RETENTION_API_METRICS_DAYS` (default 90) | PG time-based delete; **CH mode: 90-day table TTL**. | +| `system_logs` | `RETENTION_SYSTEM_LOGS_DAYS` (default 30) | PG time-based delete; **CH mode: 30-day table TTL**. | +| `error_logs` | `RETENTION_ERROR_LOGS_DAYS` (default 90) | PG time-based delete; **CH mode: NO TTL by design** — GDPR erasure mutations (`observability/analytics-gdpr.ts`) are the only eraser. | +| `user_activities` | `RETENTION_USER_ACTIVITIES_DAYS` (default 180) | PG time-based delete; **CH mode: 180-day table TTL**. | +| `ai_usage_logs` | `RETENTION_AI_USAGE_LOGS_DAYS` (default 90) | Time-based delete (purge-ai-usage-logs cron). Stays in main PG permanently. | All windows are positive integers (days). Unset, zero, negative, or non-numeric values fall back to the defaults above. This makes retention tunable per deployment (including tenant deployments) without a code change. See `packages/lib/src/compliance/retention/monitoring-retention.ts`. +## ClickHouse analytics tier (#890 Phase 3) + +With `CLICKHOUSE_ENABLED=true`, new rows for the 4 analytics tables +(`api_metrics`, `system_logs`, `user_activities`, `error_logs`) land only in +ClickHouse. Retention for **three** of them (`api_metrics` 90d, `system_logs` +30d, `user_activities` 180d) is enforced by the table TTLs above +(`packages/lib/src/observability/clickhouse-ddl.ts`); `error_logs` carries **NO +TTL by design** and is erased solely by GDPR mutations (see its row above and +`observability/analytics-gdpr.ts`). In that mode +`runMonitoringRetentionCleanup` is a no-op for those tables — the PG copies +hold only frozen pre-cutover history until the deferred Phase 6 drop +(`scripts/deferred-migrations/0890-phase6-drop-analytics-pg-tables.sql`). +GDPR Art 15 export unions both stores and Art 17 erasure deletes from both +stores while that transition window is open (`gdpr-export.ts`, +`monitoring-purge.ts`). + +**TTL timing.** ClickHouse removes TTL-expired *stored* rows lazily, during +background part merges (or an explicit `OPTIMIZE TABLE … FINAL`) — an aged row +can stay queryable until its part is next merged, so the windows above are +upper bounds on visibility, not exact. Separately, because `optimize_on_insert` +is on by default, a row **inserted** with a timestamp already past the table's +TTL horizon is dropped at insert time — which is what constrains any future +PG→CH history backfill (backfilled rows older than the horizon silently vanish). + ## Archival (`activity_logs` hot→cold tier) `activity_logs` rows are **never deleted** — the tamper-evident hash chain diff --git a/infrastructure/__tests__/root-compose.test.ts b/infrastructure/__tests__/root-compose.test.ts index 96ecabb61d..21eaa9c414 100644 --- a/infrastructure/__tests__/root-compose.test.ts +++ b/infrastructure/__tests__/root-compose.test.ts @@ -94,6 +94,63 @@ describe('Root (dev/self-host) docker-compose configuration', () => { }); }); + describe('clickhouse service (analytics tier, #890 Phase 3 — dev/CI only)', () => { + // Production analytics is cloud-managed ClickHouse Cloud; this container + // exists so dev + integration tests have a real CH. The tenant/onprem + // compose stacks deliberately do NOT run it. + it('given the root compose, should define the clickhouse service', () => { + expect(compose.services.clickhouse).toBeDefined(); + }); + + it('given the clickhouse service, should pin an exact clickhouse-server image version (no latest/floating tag)', () => { + const image = compose.services.clickhouse.image ?? ''; + expect(image).toMatch(/^clickhouse\/clickhouse-server:\d+\.\d+/); + expect(image).not.toContain('latest'); + }); + + it('given the clickhouse service, should mount its own clickhouse_data volume', () => { + const vols = compose.services.clickhouse.volumes ?? []; + expect(vols.some(v => v.startsWith('clickhouse_data:'))).toBe(true); + }); + + it('given the compose file, should define the clickhouse_data volume', () => { + expect(compose.volumes).toHaveProperty('clickhouse_data'); + }); + + it('given the clickhouse service, should expose the HTTP interface on host port 8123 (@clickhouse/client speaks HTTP only)', () => { + const ports = compose.services.clickhouse.ports ?? []; + expect(ports.some(p => String(p).startsWith('8123:'))).toBe(true); + }); + + it('given the clickhouse service, should create the pagespace_analytics database with dev credentials', () => { + const env = getEnv(compose, 'clickhouse'); + expect(env.CLICKHOUSE_DB).toBe('pagespace_analytics'); + expect(env.CLICKHOUSE_USER).toBeTruthy(); + expect(env.CLICKHOUSE_PASSWORD).toBeTruthy(); + }); + + it('given the clickhouse service, should have a /ping healthcheck', () => { + const test = compose.services.clickhouse.healthcheck?.test; + const testStr = Array.isArray(test) ? test.join(' ') : test; + expect(testStr).toContain('8123/ping'); + }); + + it('given the clickhouse service, should join only the internal network', () => { + const nets = compose.services.clickhouse.networks; + if (Array.isArray(nets)) { + expect(nets).toEqual(['internal']); + } else { + expect(Object.keys(nets ?? {})).toEqual(['internal']); + } + }); + + it('given the app services, should NOT hard-wire CLICKHOUSE_ENABLED on (ships dark; devs opt in via .env)', () => { + for (const svc of ['web', 'admin', 'processor', 'realtime']) { + expect(getEnv(compose, svc).CLICKHOUSE_ENABLED).not.toBe('true'); + } + }); + }); + describe('migrate one-shot', () => { it('given the migrate service, should depend on both postgres and postgres-admin being healthy', () => { const deps = compose.services.migrate.depends_on; diff --git a/packages/db/drizzle/0196_married_blink.sql b/packages/db/drizzle/0196_married_blink.sql new file mode 100644 index 0000000000..e310dab75c --- /dev/null +++ b/packages/db/drizzle/0196_married_blink.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS "error_resolutions" ( + "error_id" text PRIMARY KEY NOT NULL, + "resolved" boolean DEFAULT true NOT NULL, + "resolved_at" timestamp DEFAULT now() NOT NULL, + "resolved_by" text, + "resolution" text +); diff --git a/packages/db/drizzle/meta/0196_snapshot.json b/packages/db/drizzle/meta/0196_snapshot.json new file mode 100644 index 0000000000..ae06273229 --- /dev/null +++ b/packages/db/drizzle/meta/0196_snapshot.json @@ -0,0 +1,19591 @@ +{ + "id": "f135010e-5d6e-4498-ab0a-2b55e177fa8f", + "prevId": "a188671d-9f88-4a66-a015-706c7f01a40e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.device_tokens": { + "name": "device_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenPrefix": { + "name": "tokenPrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deviceId": { + "name": "deviceId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "PlatformType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "deviceName": { + "name": "deviceName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokenVersion": { + "name": "tokenVersion", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastIpAddress": { + "name": "lastIpAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trustScore": { + "name": "trustScore", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "suspiciousActivityCount": { + "name": "suspiciousActivityCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revokedReason": { + "name": "revokedReason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replacedByTokenId": { + "name": "replacedByTokenId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_tokens_user_id_idx": { + "name": "device_tokens_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_tokens_token_hash_idx": { + "name": "device_tokens_token_hash_idx", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_tokens_device_id_idx": { + "name": "device_tokens_device_id_idx", + "columns": [ + { + "expression": "deviceId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_tokens_expires_at_idx": { + "name": "device_tokens_expires_at_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_tokens_active_device_idx": { + "name": "device_tokens_active_device_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deviceId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"device_tokens\".\"revokedAt\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_tokens_userId_users_id_fk": { + "name": "device_tokens_userId_users_id_fk", + "tableFrom": "device_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "device_tokens_tokenHash_unique": { + "name": "device_tokens_tokenHash_unique", + "nullsNotDistinct": false, + "columns": [ + "tokenHash" + ] + } + } + }, + "public.email_unsubscribe_tokens": { + "name": "email_unsubscribe_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_unsubscribe_tokens_token_hash_idx": { + "name": "email_unsubscribe_tokens_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_unsubscribe_tokens_user_id_idx": { + "name": "email_unsubscribe_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_unsubscribe_tokens_expires_at_idx": { + "name": "email_unsubscribe_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_unsubscribe_tokens_user_id_users_id_fk": { + "name": "email_unsubscribe_tokens_user_id_users_id_fk", + "tableFrom": "email_unsubscribe_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_unsubscribe_tokens_token_hash_unique": { + "name": "email_unsubscribe_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + } + }, + "public.mcp_tokens": { + "name": "mcp_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenPrefix": { + "name": "tokenPrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isScoped": { + "name": "isScoped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "lastUsed": { + "name": "lastUsed", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "mcp_tokens_user_id_idx": { + "name": "mcp_tokens_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_tokens_token_hash_idx": { + "name": "mcp_tokens_token_hash_idx", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_tokens_userId_users_id_fk": { + "name": "mcp_tokens_userId_users_id_fk", + "tableFrom": "mcp_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_tokens_tokenHash_unique": { + "name": "mcp_tokens_tokenHash_unique", + "nullsNotDistinct": false, + "columns": [ + "tokenHash" + ] + } + } + }, + "public.passkeys": { + "name": "passkeys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "passkeys_user_id_idx": { + "name": "passkeys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkeys_credential_id_idx": { + "name": "passkeys_credential_id_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkeys_user_id_users_id_fk": { + "name": "passkeys_user_id_users_id_fk", + "tableFrom": "passkeys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkeys_credential_id_unique": { + "name": "passkeys_credential_id_unique", + "nullsNotDistinct": false, + "columns": [ + "credential_id" + ] + } + } + }, + "public.socket_tokens": { + "name": "socket_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "socket_tokens_user_id_idx": { + "name": "socket_tokens_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "socket_tokens_token_hash_idx": { + "name": "socket_tokens_token_hash_idx", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "socket_tokens_expires_at_idx": { + "name": "socket_tokens_expires_at_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "socket_tokens_userId_users_id_fk": { + "name": "socket_tokens_userId_users_id_fk", + "tableFrom": "socket_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "socket_tokens_tokenHash_unique": { + "name": "socket_tokens_tokenHash_unique", + "nullsNotDistinct": false, + "columns": [ + "tokenHash" + ] + } + } + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailBidx": { + "name": "emailBidx", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "googleId": { + "name": "googleId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "appleId": { + "name": "appleId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "AuthProvider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'email'" + }, + "tokenVersion": { + "name": "tokenVersion", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "role": { + "name": "role", + "type": "UserRole", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "adminRoleVersion": { + "name": "adminRoleVersion", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currentAiProvider": { + "name": "currentAiProvider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openai'" + }, + "currentAiModel": { + "name": "currentAiModel", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openai/gpt-5.3-chat'" + }, + "storageUsedBytes": { + "name": "storageUsedBytes", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "activeUploads": { + "name": "activeUploads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lastStorageCalculated": { + "name": "lastStorageCalculated", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptionTier": { + "name": "subscriptionTier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "tosAcceptedAt": { + "name": "tosAcceptedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failedLoginAttempts": { + "name": "failedLoginAttempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lockedUntil": { + "name": "lockedUntil", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspendedAt": { + "name": "suspendedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspendedReason": { + "name": "suspendedReason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_bidx_idx": { + "name": "users_email_bidx_idx", + "columns": [ + { + "expression": "emailBidx", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_googleId_unique": { + "name": "users_googleId_unique", + "nullsNotDistinct": false, + "columns": [ + "googleId" + ] + }, + "users_appleId_unique": { + "name": "users_appleId_unique", + "nullsNotDistinct": false, + "columns": [ + "appleId" + ] + }, + "users_stripeCustomerId_unique": { + "name": "users_stripeCustomerId_unique", + "nullsNotDistinct": false, + "columns": [ + "stripeCustomerId" + ] + } + } + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenPrefix": { + "name": "tokenPrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "usedAt": { + "name": "usedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_tokens_user_id_idx": { + "name": "verification_tokens_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_tokens_token_hash_idx": { + "name": "verification_tokens_token_hash_idx", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_tokens_type_idx": { + "name": "verification_tokens_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_tokens_userId_users_id_fk": { + "name": "verification_tokens_userId_users_id_fk", + "tableFrom": "verification_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_tokens_tokenHash_unique": { + "name": "verification_tokens_tokenHash_unique", + "nullsNotDistinct": false, + "columns": [ + "tokenHash" + ] + } + } + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_version": { + "name": "token_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "admin_role_version": { + "name": "admin_role_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by_service": { + "name": "created_by_service", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_ip": { + "name": "created_by_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_used_ip": { + "name": "last_used_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_active_idx": { + "name": "sessions_user_active_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_device_idx": { + "name": "sessions_user_device_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_hash_unique": { + "name": "sessions_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + } + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversationId": { + "name": "conversationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "toolCalls": { + "name": "toolCalls", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "toolResults": { + "name": "toolResults", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "editedAt": { + "name": "editedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sourceAgentId": { + "name": "sourceAgentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messageType": { + "name": "messageType", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + } + }, + "indexes": { + "chat_messages_page_id_idx": { + "name": "chat_messages_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_messages_user_id_idx": { + "name": "chat_messages_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_messages_conversation_id_idx": { + "name": "chat_messages_conversation_id_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_messages_page_id_conversation_id_idx": { + "name": "chat_messages_page_id_conversation_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_messages_page_id_is_active_created_at_idx": { + "name": "chat_messages_page_id_is_active_created_at_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isActive", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_pageId_pages_id_fk": { + "name": "chat_messages_pageId_pages_id_fk", + "tableFrom": "chat_messages", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_messages_userId_users_id_fk": { + "name": "chat_messages_userId_users_id_fk", + "tableFrom": "chat_messages", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_messages_sourceAgentId_pages_id_fk": { + "name": "chat_messages_sourceAgentId_pages_id_fk", + "tableFrom": "chat_messages", + "tableTo": "pages", + "columnsFrom": [ + "sourceAgentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.drives": { + "name": "drives", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "DriveKind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'STANDARD'" + }, + "isTrashed": { + "name": "isTrashed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trashedAt": { + "name": "trashedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "drivePrompt": { + "name": "drivePrompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publishSubdomain": { + "name": "publishSubdomain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "homePageId": { + "name": "homePageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publish_default_og_image_url": { + "name": "publish_default_og_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "not_found_page_id": { + "name": "not_found_page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publish_favicon_url": { + "name": "publish_favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drives_owner_id_idx": { + "name": "drives_owner_id_idx", + "columns": [ + { + "expression": "ownerId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drives_owner_id_slug_key": { + "name": "drives_owner_id_slug_key", + "columns": [ + { + "expression": "ownerId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drives_owner_home_unique": { + "name": "drives_owner_home_unique", + "columns": [ + { + "expression": "ownerId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"drives\".\"kind\" = 'HOME'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drives_ownerId_users_id_fk": { + "name": "drives_ownerId_users_id_fk", + "tableFrom": "drives", + "tableTo": "users", + "columnsFrom": [ + "ownerId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "drives_homePageId_pages_id_fk": { + "name": "drives_homePageId_pages_id_fk", + "tableFrom": "drives", + "tableTo": "pages", + "columnsFrom": [ + "homePageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "drives_not_found_page_id_pages_id_fk": { + "name": "drives_not_found_page_id_pages_id_fk", + "tableFrom": "drives", + "tableTo": "pages", + "columnsFrom": [ + "not_found_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "drives_publishSubdomain_unique": { + "name": "drives_publishSubdomain_unique", + "nullsNotDistinct": false, + "columns": [ + "publishSubdomain" + ] + } + } + }, + "public.favorites": { + "name": "favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "itemType": { + "name": "itemType", + "type": "FavoriteItemType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'page'" + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "favorites_user_id_page_id_key": { + "name": "favorites_user_id_page_id_key", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "favorites_user_id_drive_id_key": { + "name": "favorites_user_id_drive_id_key", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "favorites_user_id_position_idx": { + "name": "favorites_user_id_position_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "favorites_userId_users_id_fk": { + "name": "favorites_userId_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "favorites_pageId_pages_id_fk": { + "name": "favorites_pageId_pages_id_fk", + "tableFrom": "favorites", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "favorites_driveId_drives_id_fk": { + "name": "favorites_driveId_drives_id_fk", + "tableFrom": "favorites", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.mentions": { + "name": "mentions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sourcePageId": { + "name": "sourcePageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "targetPageId": { + "name": "targetPageId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mentions_source_page_id_target_page_id_key": { + "name": "mentions_source_page_id_target_page_id_key", + "columns": [ + { + "expression": "sourcePageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "targetPageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mentions_source_page_id_idx": { + "name": "mentions_source_page_id_idx", + "columns": [ + { + "expression": "sourcePageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mentions_target_page_id_idx": { + "name": "mentions_target_page_id_idx", + "columns": [ + { + "expression": "targetPageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mentions_sourcePageId_pages_id_fk": { + "name": "mentions_sourcePageId_pages_id_fk", + "tableFrom": "mentions", + "tableTo": "pages", + "columnsFrom": [ + "sourcePageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mentions_targetPageId_pages_id_fk": { + "name": "mentions_targetPageId_pages_id_fk", + "tableFrom": "mentions", + "tableTo": "pages", + "columnsFrom": [ + "targetPageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.page_tags": { + "name": "page_tags", + "schema": "", + "columns": { + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "page_tags_pageId_pages_id_fk": { + "name": "page_tags_pageId_pages_id_fk", + "tableFrom": "page_tags", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_tags_tagId_tags_id_fk": { + "name": "page_tags_tagId_tags_id_fk", + "tableFrom": "page_tags", + "tableTo": "tags", + "columnsFrom": [ + "tagId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "page_tags_pageId_tagId_pk": { + "name": "page_tags_pageId_tagId_pk", + "columns": [ + "pageId", + "tagId" + ] + } + }, + "uniqueConstraints": {} + }, + "public.pages": { + "name": "pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "PageType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "contentMode": { + "name": "contentMode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'html'" + }, + "isPaginated": { + "name": "isPaginated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "isTrashed": { + "name": "isTrashed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "aiProvider": { + "name": "aiProvider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aiModel": { + "name": "aiModel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "systemPrompt": { + "name": "systemPrompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabledTools": { + "name": "enabledTools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "includeDrivePrompt": { + "name": "includeDrivePrompt", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "agentDefinition": { + "name": "agentDefinition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibleToGlobalAssistant": { + "name": "visibleToGlobalAssistant", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "includePageTree": { + "name": "includePageTree", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pageTreeScope": { + "name": "pageTreeScope", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'children'" + }, + "toolExposureMode": { + "name": "toolExposureMode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'upfront'" + }, + "userScopedAccess": { + "name": "userScopedAccess", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "terminalAccess": { + "name": "terminalAccess", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "machines": { + "name": "machines", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowPageAgents": { + "name": "allowPageAgents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "fileSize": { + "name": "fileSize", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "mimeType": { + "name": "mimeType", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "originalFileName": { + "name": "originalFileName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filePath": { + "name": "filePath", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fileMetadata": { + "name": "fileMetadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processingStatus": { + "name": "processingStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "processingError": { + "name": "processingError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processedAt": { + "name": "processedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "extractionMethod": { + "name": "extractionMethod", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "extractionMetadata": { + "name": "extractionMetadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contentHash": { + "name": "contentHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "excludeFromSearch": { + "name": "excludeFromSearch", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isPrivate": { + "name": "isPrivate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdBy": { + "name": "createdBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "trashedAt": { + "name": "trashedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stateHash": { + "name": "stateHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "originalParentId": { + "name": "originalParentId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pages_drive_id_idx": { + "name": "pages_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_parent_id_idx": { + "name": "pages_parent_id_idx", + "columns": [ + { + "expression": "parentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_parent_id_position_idx": { + "name": "pages_parent_id_position_idx", + "columns": [ + { + "expression": "parentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_drive_id_is_trashed_type_idx": { + "name": "pages_drive_id_is_trashed_type_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isTrashed", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pages_createdBy_users_id_fk": { + "name": "pages_createdBy_users_id_fk", + "tableFrom": "pages", + "tableTo": "users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pages_driveId_drives_id_fk": { + "name": "pages_driveId_drives_id_fk", + "tableFrom": "pages", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.storage_events": { + "name": "storage_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eventType": { + "name": "eventType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sizeDelta": { + "name": "sizeDelta", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "totalSizeAfter": { + "name": "totalSizeAfter", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "storage_events_user_id_idx": { + "name": "storage_events_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "storage_events_created_at_idx": { + "name": "storage_events_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "storage_events_userId_users_id_fk": { + "name": "storage_events_userId_users_id_fk", + "tableFrom": "storage_events", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "storage_events_pageId_pages_id_fk": { + "name": "storage_events_pageId_pages_id_fk", + "tableFrom": "storage_events", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + } + }, + "public.user_mentions": { + "name": "user_mentions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sourcePageId": { + "name": "sourcePageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "targetUserId": { + "name": "targetUserId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mentionedByUserId": { + "name": "mentionedByUserId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_mentions_source_page_id_target_user_id_key": { + "name": "user_mentions_source_page_id_target_user_id_key", + "columns": [ + { + "expression": "sourcePageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "targetUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_mentions_source_page_id_idx": { + "name": "user_mentions_source_page_id_idx", + "columns": [ + { + "expression": "sourcePageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_mentions_target_user_id_idx": { + "name": "user_mentions_target_user_id_idx", + "columns": [ + { + "expression": "targetUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_mentions_sourcePageId_pages_id_fk": { + "name": "user_mentions_sourcePageId_pages_id_fk", + "tableFrom": "user_mentions", + "tableTo": "pages", + "columnsFrom": [ + "sourcePageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_mentions_targetUserId_users_id_fk": { + "name": "user_mentions_targetUserId_users_id_fk", + "tableFrom": "user_mentions", + "tableTo": "users", + "columnsFrom": [ + "targetUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_mentions_mentionedByUserId_users_id_fk": { + "name": "user_mentions_mentionedByUserId_users_id_fk", + "tableFrom": "user_mentions", + "tableTo": "users", + "columnsFrom": [ + "mentionedByUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "action": { + "name": "action", + "type": "PermissionAction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "subjectType": { + "name": "subjectType", + "type": "SubjectType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "subjectId": { + "name": "subjectId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_page_id_idx": { + "name": "permissions_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_subject_id_subject_type_idx": { + "name": "permissions_subject_id_subject_type_idx", + "columns": [ + { + "expression": "subjectId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subjectType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_page_id_subject_id_subject_type_idx": { + "name": "permissions_page_id_subject_id_subject_type_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subjectId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subjectType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_pageId_pages_id_fk": { + "name": "permissions_pageId_pages_id_fk", + "tableFrom": "permissions", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.drive_agent_members": { + "name": "drive_agent_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agentPageId": { + "name": "agentPageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "MemberRole", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "customRoleId": { + "name": "customRoleId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "includeContext": { + "name": "includeContext", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "addedBy": { + "name": "addedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "addedAt": { + "name": "addedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "drive_agent_members_drive_id_idx": { + "name": "drive_agent_members_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_agent_members_agent_page_id_idx": { + "name": "drive_agent_members_agent_page_id_idx", + "columns": [ + { + "expression": "agentPageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_agent_members_driveId_drives_id_fk": { + "name": "drive_agent_members_driveId_drives_id_fk", + "tableFrom": "drive_agent_members", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "drive_agent_members_agentPageId_pages_id_fk": { + "name": "drive_agent_members_agentPageId_pages_id_fk", + "tableFrom": "drive_agent_members", + "tableTo": "pages", + "columnsFrom": [ + "agentPageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "drive_agent_members_customRoleId_drive_roles_id_fk": { + "name": "drive_agent_members_customRoleId_drive_roles_id_fk", + "tableFrom": "drive_agent_members", + "tableTo": "drive_roles", + "columnsFrom": [ + "customRoleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "drive_agent_members_addedBy_users_id_fk": { + "name": "drive_agent_members_addedBy_users_id_fk", + "tableFrom": "drive_agent_members", + "tableTo": "users", + "columnsFrom": [ + "addedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "drive_agent_members_drive_agent_key": { + "name": "drive_agent_members_drive_agent_key", + "nullsNotDistinct": false, + "columns": [ + "driveId", + "agentPageId" + ] + } + } + }, + "public.drive_members": { + "name": "drive_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "MemberRole", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "customRoleId": { + "name": "customRoleId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invitedBy": { + "name": "invitedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invitedAt": { + "name": "invitedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastAccessedAt": { + "name": "lastAccessedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drive_members_drive_id_idx": { + "name": "drive_members_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_members_user_id_idx": { + "name": "drive_members_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_members_role_idx": { + "name": "drive_members_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_members_custom_role_id_idx": { + "name": "drive_members_custom_role_id_idx", + "columns": [ + { + "expression": "customRoleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_members_driveId_drives_id_fk": { + "name": "drive_members_driveId_drives_id_fk", + "tableFrom": "drive_members", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "drive_members_userId_users_id_fk": { + "name": "drive_members_userId_users_id_fk", + "tableFrom": "drive_members", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "drive_members_customRoleId_drive_roles_id_fk": { + "name": "drive_members_customRoleId_drive_roles_id_fk", + "tableFrom": "drive_members", + "tableTo": "drive_roles", + "columnsFrom": [ + "customRoleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "drive_members_invitedBy_users_id_fk": { + "name": "drive_members_invitedBy_users_id_fk", + "tableFrom": "drive_members", + "tableTo": "users", + "columnsFrom": [ + "invitedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "drive_members_drive_user_key": { + "name": "drive_members_drive_user_key", + "nullsNotDistinct": false, + "columns": [ + "driveId", + "userId" + ] + } + } + }, + "public.drive_roles": { + "name": "drive_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isDefault": { + "name": "isDefault", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "drive_wide_permissions": { + "name": "drive_wide_permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "NULL" + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "drive_roles_drive_id_idx": { + "name": "drive_roles_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_roles_position_idx": { + "name": "drive_roles_position_idx", + "columns": [ + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_roles_driveId_drives_id_fk": { + "name": "drive_roles_driveId_drives_id_fk", + "tableFrom": "drive_roles", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "drive_roles_drive_name_key": { + "name": "drive_roles_drive_name_key", + "nullsNotDistinct": false, + "columns": [ + "driveId", + "name" + ] + } + } + }, + "public.mcp_token_drives": { + "name": "mcp_token_drives", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokenId": { + "name": "tokenId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "MemberRole", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "customRoleId": { + "name": "customRoleId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "addedBy": { + "name": "addedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_token_drives_token_id_idx": { + "name": "mcp_token_drives_token_id_idx", + "columns": [ + { + "expression": "tokenId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_token_drives_drive_id_idx": { + "name": "mcp_token_drives_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_token_drives_token_drive_unique": { + "name": "mcp_token_drives_token_drive_unique", + "columns": [ + { + "expression": "tokenId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_token_drives_tokenId_mcp_tokens_id_fk": { + "name": "mcp_token_drives_tokenId_mcp_tokens_id_fk", + "tableFrom": "mcp_token_drives", + "tableTo": "mcp_tokens", + "columnsFrom": [ + "tokenId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_token_drives_driveId_drives_id_fk": { + "name": "mcp_token_drives_driveId_drives_id_fk", + "tableFrom": "mcp_token_drives", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_token_drives_customRoleId_drive_roles_id_fk": { + "name": "mcp_token_drives_customRoleId_drive_roles_id_fk", + "tableFrom": "mcp_token_drives", + "tableTo": "drive_roles", + "columnsFrom": [ + "customRoleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_token_drives_addedBy_users_id_fk": { + "name": "mcp_token_drives_addedBy_users_id_fk", + "tableFrom": "mcp_token_drives", + "tableTo": "users", + "columnsFrom": [ + "addedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.page_permissions": { + "name": "page_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canView": { + "name": "canView", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canEdit": { + "name": "canEdit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canShare": { + "name": "canShare", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canDelete": { + "name": "canDelete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "grantedBy": { + "name": "grantedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grantedAt": { + "name": "grantedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "page_permissions_page_id_idx": { + "name": "page_permissions_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_permissions_user_id_idx": { + "name": "page_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_permissions_expires_at_idx": { + "name": "page_permissions_expires_at_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "page_permissions_pageId_pages_id_fk": { + "name": "page_permissions_pageId_pages_id_fk", + "tableFrom": "page_permissions", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_permissions_userId_users_id_fk": { + "name": "page_permissions_userId_users_id_fk", + "tableFrom": "page_permissions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_permissions_grantedBy_users_id_fk": { + "name": "page_permissions_grantedBy_users_id_fk", + "tableFrom": "page_permissions", + "tableTo": "users", + "columnsFrom": [ + "grantedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "page_permissions_page_user_key": { + "name": "page_permissions_page_user_key", + "nullsNotDistinct": false, + "columns": [ + "pageId", + "userId" + ] + } + } + }, + "public.user_profiles": { + "name": "user_profiles", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "displayName": { + "name": "displayName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatarUrl": { + "name": "avatarUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_profiles_is_public_idx": { + "name": "user_profiles_is_public_idx", + "columns": [ + { + "expression": "isPublic", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_profiles_userId_users_id_fk": { + "name": "user_profiles_userId_users_id_fk", + "tableFrom": "user_profiles", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.channel_message_reactions": { + "name": "channel_message_reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "messageId": { + "name": "messageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_reaction_idx": { + "name": "unique_reaction_idx", + "columns": [ + { + "expression": "messageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "emoji", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reaction_message_idx": { + "name": "reaction_message_idx", + "columns": [ + { + "expression": "messageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channel_message_reactions_messageId_channel_messages_id_fk": { + "name": "channel_message_reactions_messageId_channel_messages_id_fk", + "tableFrom": "channel_message_reactions", + "tableTo": "channel_messages", + "columnsFrom": [ + "messageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_message_reactions_userId_users_id_fk": { + "name": "channel_message_reactions_userId_users_id_fk", + "tableFrom": "channel_message_reactions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.channel_messages": { + "name": "channel_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachmentMeta": { + "name": "attachmentMeta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "editedAt": { + "name": "editedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "aiMeta": { + "name": "aiMeta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replyCount": { + "name": "replyCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lastReplyAt": { + "name": "lastReplyAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "mirroredFromId": { + "name": "mirroredFromId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quotedMessageId": { + "name": "quotedMessageId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "channel_messages_page_id_idx": { + "name": "channel_messages_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channel_messages_file_id_idx": { + "name": "channel_messages_file_id_idx", + "columns": [ + { + "expression": "fileId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channel_messages_parent_created_idx": { + "name": "channel_messages_parent_created_idx", + "columns": [ + { + "expression": "parentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channel_messages_quoted_id_idx": { + "name": "channel_messages_quoted_id_idx", + "columns": [ + { + "expression": "quotedMessageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channel_messages_pageId_pages_id_fk": { + "name": "channel_messages_pageId_pages_id_fk", + "tableFrom": "channel_messages", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_messages_userId_users_id_fk": { + "name": "channel_messages_userId_users_id_fk", + "tableFrom": "channel_messages", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_messages_fileId_files_id_fk": { + "name": "channel_messages_fileId_files_id_fk", + "tableFrom": "channel_messages", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channel_messages_parentId_channel_messages_id_fk": { + "name": "channel_messages_parentId_channel_messages_id_fk", + "tableFrom": "channel_messages", + "tableTo": "channel_messages", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_messages_mirroredFromId_channel_messages_id_fk": { + "name": "channel_messages_mirroredFromId_channel_messages_id_fk", + "tableFrom": "channel_messages", + "tableTo": "channel_messages", + "columnsFrom": [ + "mirroredFromId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channel_messages_quotedMessageId_channel_messages_id_fk": { + "name": "channel_messages_quotedMessageId_channel_messages_id_fk", + "tableFrom": "channel_messages", + "tableTo": "channel_messages", + "columnsFrom": [ + "quotedMessageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.channel_read_status": { + "name": "channel_read_status", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channelId": { + "name": "channelId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastReadAt": { + "name": "lastReadAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channel_read_status_user_id_idx": { + "name": "channel_read_status_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channel_read_status_channel_id_idx": { + "name": "channel_read_status_channel_id_idx", + "columns": [ + { + "expression": "channelId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channel_read_status_userId_users_id_fk": { + "name": "channel_read_status_userId_users_id_fk", + "tableFrom": "channel_read_status", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_read_status_channelId_pages_id_fk": { + "name": "channel_read_status_channelId_pages_id_fk", + "tableFrom": "channel_read_status", + "tableTo": "pages", + "columnsFrom": [ + "channelId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_read_status_userId_channelId_pk": { + "name": "channel_read_status_userId_channelId_pk", + "columns": [ + "userId", + "channelId" + ] + } + }, + "uniqueConstraints": {} + }, + "public.channel_thread_followers": { + "name": "channel_thread_followers", + "schema": "", + "columns": { + "rootMessageId": { + "name": "rootMessageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channel_thread_followers_user_id_idx": { + "name": "channel_thread_followers_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channel_thread_followers_rootMessageId_channel_messages_id_fk": { + "name": "channel_thread_followers_rootMessageId_channel_messages_id_fk", + "tableFrom": "channel_thread_followers", + "tableTo": "channel_messages", + "columnsFrom": [ + "rootMessageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_thread_followers_userId_users_id_fk": { + "name": "channel_thread_followers_userId_users_id_fk", + "tableFrom": "channel_thread_followers", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_thread_followers_rootMessageId_userId_pk": { + "name": "channel_thread_followers_rootMessageId_userId_pk", + "columns": [ + "rootMessageId", + "userId" + ] + } + }, + "uniqueConstraints": {} + }, + "public.pulse_summaries": { + "name": "pulse_summaries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "greeting": { + "name": "greeting", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "pulse_summary_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "contextData": { + "name": "contextData", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "aiProvider": { + "name": "aiProvider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aiModel": { + "name": "aiModel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "periodStart": { + "name": "periodStart", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "periodEnd": { + "name": "periodEnd", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "generatedAt": { + "name": "generatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_pulse_summaries_user_id": { + "name": "idx_pulse_summaries_user_id", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pulse_summaries_generated_at": { + "name": "idx_pulse_summaries_generated_at", + "columns": [ + { + "expression": "generatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pulse_summaries_expires_at": { + "name": "idx_pulse_summaries_expires_at", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pulse_summaries_user_generated": { + "name": "idx_pulse_summaries_user_generated", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pulse_summaries_userId_users_id_fk": { + "name": "pulse_summaries_userId_users_id_fk", + "tableFrom": "pulse_summaries", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_dashboards": { + "name": "user_dashboards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_dashboards_userId_users_id_fk": { + "name": "user_dashboards_userId_users_id_fk", + "tableFrom": "user_dashboards", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_dashboards_userId_unique": { + "name": "user_dashboards_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + } + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contextId": { + "name": "contextId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMessageAt": { + "name": "lastMessageAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "isShared": { + "name": "isShared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "conversations_user_id_idx": { + "name": "conversations_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_user_id_type_idx": { + "name": "conversations_user_id_type_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_user_id_last_message_at_idx": { + "name": "conversations_user_id_last_message_at_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lastMessageAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_context_id_idx": { + "name": "conversations_context_id_idx", + "columns": [ + { + "expression": "contextId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_userId_users_id_fk": { + "name": "conversations_userId_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversationId": { + "name": "conversationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "messageType": { + "name": "messageType", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "toolCalls": { + "name": "toolCalls", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "toolResults": { + "name": "toolResults", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "editedAt": { + "name": "editedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "messages_conversation_id_idx": { + "name": "messages_conversation_id_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_conversation_id_created_at_idx": { + "name": "messages_conversation_id_created_at_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_user_id_idx": { + "name": "messages_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversationId_conversations_id_fk": { + "name": "messages_conversationId_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_userId_users_id_fk": { + "name": "messages_userId_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "NotificationType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "isRead": { + "name": "isRead", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "readAt": { + "name": "readAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "triggeredByUserId": { + "name": "triggeredByUserId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "notifications_user_id_idx": { + "name": "notifications_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_id_is_read_idx": { + "name": "notifications_user_id_is_read_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isRead", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_id_is_read_created_at_idx": { + "name": "notifications_user_id_is_read_created_at_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isRead", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_created_at_idx": { + "name": "notifications_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_type_idx": { + "name": "notifications_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_userId_users_id_fk": { + "name": "notifications_userId_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_pageId_pages_id_fk": { + "name": "notifications_pageId_pages_id_fk", + "tableFrom": "notifications", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_driveId_drives_id_fk": { + "name": "notifications_driveId_drives_id_fk", + "tableFrom": "notifications", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_triggeredByUserId_users_id_fk": { + "name": "notifications_triggeredByUserId_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "triggeredByUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.email_notification_log": { + "name": "email_notification_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notificationId": { + "name": "notificationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notificationType": { + "name": "notificationType", + "type": "NotificationType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "recipientEmail": { + "name": "recipientEmail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sentAt": { + "name": "sentAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_notification_log_user_idx": { + "name": "email_notification_log_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_notification_log_sent_at_idx": { + "name": "email_notification_log_sent_at_idx", + "columns": [ + { + "expression": "sentAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_notification_log_notification_id_idx": { + "name": "email_notification_log_notification_id_idx", + "columns": [ + { + "expression": "notificationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_notification_log_userId_users_id_fk": { + "name": "email_notification_log_userId_users_id_fk", + "tableFrom": "email_notification_log", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.email_notification_preferences": { + "name": "email_notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notificationType": { + "name": "notificationType", + "type": "NotificationType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "emailEnabled": { + "name": "emailEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_notification_preferences_user_type_idx": { + "name": "email_notification_preferences_user_type_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "notificationType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_notification_preferences_userId_users_id_fk": { + "name": "email_notification_preferences_userId_users_id_fk", + "tableFrom": "email_notification_preferences", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_toast_notification_preferences": { + "name": "user_toast_notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "toast_notification_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_toast_notification_preferences_user_idx": { + "name": "user_toast_notification_preferences_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_toast_notification_preferences_userId_users_id_fk": { + "name": "user_toast_notification_preferences_userId_users_id_fk", + "tableFrom": "user_toast_notification_preferences", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.display_preferences": { + "name": "display_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preferenceType": { + "name": "preferenceType", + "type": "display_preference_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "display_preferences_user_type_idx": { + "name": "display_preferences_user_type_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "preferenceType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "display_preferences_userId_users_id_fk": { + "name": "display_preferences_userId_users_id_fk", + "tableFrom": "display_preferences", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.activity_logs": { + "name": "activity_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actorEmail": { + "name": "actorEmail", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'legacy@unknown'" + }, + "actorDisplayName": { + "name": "actorDisplayName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isAiGenerated": { + "name": "isAiGenerated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "aiProvider": { + "name": "aiProvider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aiModel": { + "name": "aiModel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aiConversationId": { + "name": "aiConversationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resourceType": { + "name": "resourceType", + "type": "activity_resource", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "resourceId": { + "name": "resourceId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resourceTitle": { + "name": "resourceTitle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contentSnapshot": { + "name": "contentSnapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contentFormat": { + "name": "contentFormat", + "type": "content_format", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "contentRef": { + "name": "contentRef", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contentSize": { + "name": "contentSize", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rollbackFromActivityId": { + "name": "rollbackFromActivityId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rollbackSourceOperation": { + "name": "rollbackSourceOperation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rollbackSourceTimestamp": { + "name": "rollbackSourceTimestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rollbackSourceTitle": { + "name": "rollbackSourceTitle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updatedFields": { + "name": "updatedFields", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "previousValues": { + "name": "previousValues", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "newValues": { + "name": "newValues", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "streamId": { + "name": "streamId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "streamSeq": { + "name": "streamSeq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "changeGroupId": { + "name": "changeGroupId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changeGroupType": { + "name": "changeGroupType", + "type": "activity_change_group_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "stateHashBefore": { + "name": "stateHashBefore", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stateHashAfter": { + "name": "stateHashAfter", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dataCategory": { + "name": "dataCategory", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "legalBasis": { + "name": "legalBasis", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retentionPolicy": { + "name": "retentionPolicy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "isArchived": { + "name": "isArchived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "chainSeq": { + "name": "chainSeq", + "type": "bigserial", + "primaryKey": false, + "notNull": true + }, + "previousLogHash": { + "name": "previousLogHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logHash": { + "name": "logHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chainSeed": { + "name": "chainSeed", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_activity_logs_timestamp": { + "name": "idx_activity_logs_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_activity_logs_user_timestamp": { + "name": "idx_activity_logs_user_timestamp", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_activity_logs_drive_timestamp": { + "name": "idx_activity_logs_drive_timestamp", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_activity_logs_page_timestamp": { + "name": "idx_activity_logs_page_timestamp", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_activity_logs_archived": { + "name": "idx_activity_logs_archived", + "columns": [ + { + "expression": "isArchived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_activity_logs_rollback_from": { + "name": "idx_activity_logs_rollback_from", + "columns": [ + { + "expression": "rollbackFromActivityId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_activity_logs_stream": { + "name": "idx_activity_logs_stream", + "columns": [ + { + "expression": "streamId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "streamSeq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"activity_logs\".\"streamId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_activity_logs_change_group": { + "name": "idx_activity_logs_change_group", + "columns": [ + { + "expression": "changeGroupId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"activity_logs\".\"changeGroupId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_activity_logs_log_hash": { + "name": "idx_activity_logs_log_hash", + "columns": [ + { + "expression": "logHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"activity_logs\".\"logHash\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_activity_logs_chain_seq": { + "name": "idx_activity_logs_chain_seq", + "columns": [ + { + "expression": "chainSeq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_logs_userId_users_id_fk": { + "name": "activity_logs_userId_users_id_fk", + "tableFrom": "activity_logs", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "activity_logs_driveId_drives_id_fk": { + "name": "activity_logs_driveId_drives_id_fk", + "tableFrom": "activity_logs", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "activity_logs_pageId_pages_id_fk": { + "name": "activity_logs_pageId_pages_id_fk", + "tableFrom": "activity_logs", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_usage_logs": { + "name": "ai_usage_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'USD'" + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "streaming_duration": { + "name": "streaming_duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "context_messages": { + "name": "context_messages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "context_size": { + "name": "context_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "system_prompt_tokens": { + "name": "system_prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tool_definition_tokens": { + "name": "tool_definition_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "conversation_tokens": { + "name": "conversation_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "was_truncated": { + "name": "was_truncated", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "truncation_strategy": { + "name": "truncation_strategy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconcile_status": { + "name": "reconcile_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconcile_attempts": { + "name": "reconcile_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_ai_usage_timestamp": { + "name": "idx_ai_usage_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_user_id": { + "name": "idx_ai_usage_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_user_source": { + "name": "idx_ai_usage_user_source", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_provider": { + "name": "idx_ai_usage_provider", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_cost": { + "name": "idx_ai_usage_cost", + "columns": [ + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_conversation": { + "name": "idx_ai_usage_conversation", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_context": { + "name": "idx_ai_usage_context", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_context_size": { + "name": "idx_ai_usage_context_size", + "columns": [ + { + "expression": "context_size", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_expires_at": { + "name": "idx_ai_usage_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_reconcile": { + "name": "idx_ai_usage_reconcile", + "columns": [ + { + "expression": "reconcile_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "reconcile_status = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.api_metrics": { + "name": "api_metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "http_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "request_size": { + "name": "request_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_size": { + "name": "response_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_hit": { + "name": "cache_hit", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cache_key": { + "name": "cache_key", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_api_metrics_timestamp": { + "name": "idx_api_metrics_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_api_metrics_endpoint": { + "name": "idx_api_metrics_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_api_metrics_user_id": { + "name": "idx_api_metrics_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_api_metrics_status": { + "name": "idx_api_metrics_status", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_api_metrics_duration": { + "name": "idx_api_metrics_duration", + "columns": [ + { + "expression": "duration", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.error_logs": { + "name": "error_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stack": { + "name": "stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "http_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "file": { + "name": "file", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line": { + "name": "line", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "column": { + "name": "column", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resolved": { + "name": "resolved", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_errors_timestamp": { + "name": "idx_errors_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_errors_name": { + "name": "idx_errors_name", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_errors_user_id": { + "name": "idx_errors_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_errors_resolved": { + "name": "idx_errors_resolved", + "columns": [ + { + "expression": "resolved", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_errors_endpoint": { + "name": "idx_errors_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.error_resolutions": { + "name": "error_resolutions", + "schema": "", + "columns": { + "error_id": { + "name": "error_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resolved": { + "name": "resolved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.siem_delivery_cursors": { + "name": "siem_delivery_cursors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "lastDeliveredId": { + "name": "lastDeliveredId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastDeliveredAt": { + "name": "lastDeliveredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastErrorAt": { + "name": "lastErrorAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deliveryCount": { + "name": "deliveryCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.siem_delivery_receipts": { + "name": "siem_delivery_receipts", + "schema": "", + "columns": { + "receiptId": { + "name": "receiptId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "deliveryId": { + "name": "deliveryId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "firstEntryId": { + "name": "firstEntryId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastEntryId": { + "name": "lastEntryId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "firstEntryTimestamp": { + "name": "firstEntryTimestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "lastEntryTimestamp": { + "name": "lastEntryTimestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "entryCount": { + "name": "entryCount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deliveredAt": { + "name": "deliveredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "webhookStatus": { + "name": "webhookStatus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "webhookResponseHash": { + "name": "webhookResponseHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ackReceivedAt": { + "name": "ackReceivedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "siem_delivery_receipts_delivery_source_unique": { + "name": "siem_delivery_receipts_delivery_source_unique", + "columns": [ + { + "expression": "deliveryId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_siem_receipts_delivery_id": { + "name": "idx_siem_receipts_delivery_id", + "columns": [ + { + "expression": "deliveryId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_siem_receipts_first_entry": { + "name": "idx_siem_receipts_first_entry", + "columns": [ + { + "expression": "firstEntryId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_siem_receipts_last_entry": { + "name": "idx_siem_receipts_last_entry", + "columns": [ + { + "expression": "lastEntryId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_siem_receipts_delivered_at": { + "name": "idx_siem_receipts_delivered_at", + "columns": [ + { + "expression": "deliveredAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_siem_receipts_source_range": { + "name": "idx_siem_receipts_source_range", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "firstEntryTimestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lastEntryTimestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.system_logs": { + "name": "system_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "level": { + "name": "level", + "type": "log_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "http_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_name": { + "name": "error_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "memory_used": { + "name": "memory_used", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "memory_total": { + "name": "memory_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_system_logs_timestamp": { + "name": "idx_system_logs_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_system_logs_level": { + "name": "idx_system_logs_level", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_system_logs_category": { + "name": "idx_system_logs_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_system_logs_user_id": { + "name": "idx_system_logs_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_system_logs_request_id": { + "name": "idx_system_logs_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_system_logs_error": { + "name": "idx_system_logs_error", + "columns": [ + { + "expression": "error_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_activities": { + "name": "user_activities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_activities_timestamp": { + "name": "idx_user_activities_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_activities_user_id": { + "name": "idx_user_activities_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_activities_action": { + "name": "idx_user_activities_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_activities_resource": { + "name": "idx_user_activities_resource", + "columns": [ + { + "expression": "resource", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.drive_backup_files": { + "name": "drive_backup_files", + "schema": "", + "columns": { + "backupId": { + "name": "backupId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storagePath": { + "name": "storagePath", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sizeBytes": { + "name": "sizeBytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mimeType": { + "name": "mimeType", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checksumVersion": { + "name": "checksumVersion", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drive_backup_files_backup_idx": { + "name": "drive_backup_files_backup_idx", + "columns": [ + { + "expression": "backupId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_backup_files_backupId_drive_backups_id_fk": { + "name": "drive_backup_files_backupId_drive_backups_id_fk", + "tableFrom": "drive_backup_files", + "tableTo": "drive_backups", + "columnsFrom": [ + "backupId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "drive_backup_files_backupId_fileId_pk": { + "name": "drive_backup_files_backupId_fileId_pk", + "columns": [ + "backupId", + "fileId" + ] + } + }, + "uniqueConstraints": {} + }, + "public.drive_backup_members": { + "name": "drive_backup_members", + "schema": "", + "columns": { + "backupId": { + "name": "backupId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customRoleId": { + "name": "customRoleId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invitedBy": { + "name": "invitedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invitedAt": { + "name": "invitedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drive_backup_members_backup_idx": { + "name": "drive_backup_members_backup_idx", + "columns": [ + { + "expression": "backupId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_backup_members_backupId_drive_backups_id_fk": { + "name": "drive_backup_members_backupId_drive_backups_id_fk", + "tableFrom": "drive_backup_members", + "tableTo": "drive_backups", + "columnsFrom": [ + "backupId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "drive_backup_members_backupId_userId_pk": { + "name": "drive_backup_members_backupId_userId_pk", + "columns": [ + "backupId", + "userId" + ] + } + }, + "uniqueConstraints": {} + }, + "public.drive_backup_pages": { + "name": "drive_backup_pages", + "schema": "", + "columns": { + "backupId": { + "name": "backupId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageVersionId": { + "name": "pageVersionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "originalParentId": { + "name": "originalParentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "isTrashed": { + "name": "isTrashed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trashedAt": { + "name": "trashedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drive_backup_pages_backup_idx": { + "name": "drive_backup_pages_backup_idx", + "columns": [ + { + "expression": "backupId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_backup_pages_backupId_drive_backups_id_fk": { + "name": "drive_backup_pages_backupId_drive_backups_id_fk", + "tableFrom": "drive_backup_pages", + "tableTo": "drive_backups", + "columnsFrom": [ + "backupId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "drive_backup_pages_pageVersionId_page_versions_id_fk": { + "name": "drive_backup_pages_pageVersionId_page_versions_id_fk", + "tableFrom": "drive_backup_pages", + "tableTo": "page_versions", + "columnsFrom": [ + "pageVersionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "drive_backup_pages_backupId_pageId_pk": { + "name": "drive_backup_pages_backupId_pageId_pk", + "columns": [ + "backupId", + "pageId" + ] + } + }, + "uniqueConstraints": {} + }, + "public.drive_backup_permissions": { + "name": "drive_backup_permissions", + "schema": "", + "columns": { + "backupId": { + "name": "backupId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canView": { + "name": "canView", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "canEdit": { + "name": "canEdit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canShare": { + "name": "canShare", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canDelete": { + "name": "canDelete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "grantedBy": { + "name": "grantedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drive_backup_permissions_backup_idx": { + "name": "drive_backup_permissions_backup_idx", + "columns": [ + { + "expression": "backupId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_backup_permissions_backupId_drive_backups_id_fk": { + "name": "drive_backup_permissions_backupId_drive_backups_id_fk", + "tableFrom": "drive_backup_permissions", + "tableTo": "drive_backups", + "columnsFrom": [ + "backupId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "drive_backup_permissions_backupId_pageId_userId_pk": { + "name": "drive_backup_permissions_backupId_pageId_userId_pk", + "columns": [ + "backupId", + "pageId", + "userId" + ] + } + }, + "uniqueConstraints": {} + }, + "public.drive_backup_roles": { + "name": "drive_backup_roles", + "schema": "", + "columns": { + "backupId": { + "name": "backupId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isDefault": { + "name": "isDefault", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "drive_wide_permissions": { + "name": "drive_wide_permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "NULL" + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drive_backup_roles_backup_idx": { + "name": "drive_backup_roles_backup_idx", + "columns": [ + { + "expression": "backupId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_backup_roles_backupId_drive_backups_id_fk": { + "name": "drive_backup_roles_backupId_drive_backups_id_fk", + "tableFrom": "drive_backup_roles", + "tableTo": "drive_backups", + "columnsFrom": [ + "backupId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "drive_backup_roles_backupId_roleId_pk": { + "name": "drive_backup_roles_backupId_roleId_pk", + "columns": [ + "backupId", + "roleId" + ] + } + }, + "uniqueConstraints": {} + }, + "public.drive_backup_schedules": { + "name": "drive_backup_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "frequency": { + "name": "frequency", + "type": "drive_backup_schedule_frequency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'daily'" + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "nextRunAt": { + "name": "nextRunAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastRunAt": { + "name": "lastRunAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "drive_backup_schedules_enabled_next_run_idx": { + "name": "drive_backup_schedules_enabled_next_run_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "nextRunAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_backup_schedules_driveId_drives_id_fk": { + "name": "drive_backup_schedules_driveId_drives_id_fk", + "tableFrom": "drive_backup_schedules", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "drive_backup_schedules_driveId_unique": { + "name": "drive_backup_schedules_driveId_unique", + "nullsNotDistinct": false, + "columns": [ + "driveId" + ] + } + } + }, + "public.drive_backups": { + "name": "drive_backups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "createdBy": { + "name": "createdBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "drive_backup_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "status": { + "name": "status", + "type": "drive_backup_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changeGroupId": { + "name": "changeGroupId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changeGroupType": { + "name": "changeGroupType", + "type": "activity_change_group_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "isPinned": { + "name": "isPinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failedAt": { + "name": "failedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failureReason": { + "name": "failureReason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drive_backups_drive_created_at_idx": { + "name": "drive_backups_drive_created_at_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_backups_status_idx": { + "name": "drive_backups_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_backups_driveId_drives_id_fk": { + "name": "drive_backups_driveId_drives_id_fk", + "tableFrom": "drive_backups", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "drive_backups_createdBy_users_id_fk": { + "name": "drive_backups_createdBy_users_id_fk", + "tableFrom": "drive_backups", + "tableTo": "users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.page_versions": { + "name": "page_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "createdBy": { + "name": "createdBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "page_version_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'auto'" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changeGroupId": { + "name": "changeGroupId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changeGroupType": { + "name": "changeGroupType", + "type": "activity_change_group_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "contentRef": { + "name": "contentRef", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contentFormat": { + "name": "contentFormat", + "type": "content_format", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "contentSize": { + "name": "contentSize", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stateHash": { + "name": "stateHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pageRevision": { + "name": "pageRevision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "isPinned": { + "name": "isPinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "page_versions_page_created_at_idx": { + "name": "page_versions_page_created_at_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_versions_drive_created_at_idx": { + "name": "page_versions_drive_created_at_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_versions_pinned_idx": { + "name": "page_versions_pinned_idx", + "columns": [ + { + "expression": "isPinned", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_versions_page_id_is_pinned_created_at_idx": { + "name": "page_versions_page_id_is_pinned_created_at_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isPinned", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "page_versions_pageId_pages_id_fk": { + "name": "page_versions_pageId_pages_id_fk", + "tableFrom": "page_versions", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_versions_driveId_drives_id_fk": { + "name": "page_versions_driveId_drives_id_fk", + "tableFrom": "page_versions", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_versions_createdBy_users_id_fk": { + "name": "page_versions_createdBy_users_id_fk", + "tableFrom": "page_versions", + "tableTo": "users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.connections": { + "name": "connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user1Id": { + "name": "user1Id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user2Id": { + "name": "user2Id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "ConnectionStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "requestedBy": { + "name": "requestedBy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requestMessage": { + "name": "requestMessage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requestedAt": { + "name": "requestedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "blockedBy": { + "name": "blockedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blockedAt": { + "name": "blockedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "connections_user1_id_idx": { + "name": "connections_user1_id_idx", + "columns": [ + { + "expression": "user1Id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_user2_id_idx": { + "name": "connections_user2_id_idx", + "columns": [ + { + "expression": "user2Id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_status_idx": { + "name": "connections_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_user1_status_idx": { + "name": "connections_user1_status_idx", + "columns": [ + { + "expression": "user1Id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_user2_status_idx": { + "name": "connections_user2_status_idx", + "columns": [ + { + "expression": "user2Id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connections_user1Id_users_id_fk": { + "name": "connections_user1Id_users_id_fk", + "tableFrom": "connections", + "tableTo": "users", + "columnsFrom": [ + "user1Id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connections_user2Id_users_id_fk": { + "name": "connections_user2Id_users_id_fk", + "tableFrom": "connections", + "tableTo": "users", + "columnsFrom": [ + "user2Id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connections_requestedBy_users_id_fk": { + "name": "connections_requestedBy_users_id_fk", + "tableFrom": "connections", + "tableTo": "users", + "columnsFrom": [ + "requestedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connections_blockedBy_users_id_fk": { + "name": "connections_blockedBy_users_id_fk", + "tableFrom": "connections", + "tableTo": "users", + "columnsFrom": [ + "blockedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "connections_user_pair_key": { + "name": "connections_user_pair_key", + "nullsNotDistinct": false, + "columns": [ + "user1Id", + "user2Id" + ] + } + } + }, + "public.direct_messages": { + "name": "direct_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversationId": { + "name": "conversationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "senderId": { + "name": "senderId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachmentMeta": { + "name": "attachmentMeta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "isRead": { + "name": "isRead", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "readAt": { + "name": "readAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "isEdited": { + "name": "isEdited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "editedAt": { + "name": "editedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replyCount": { + "name": "replyCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lastReplyAt": { + "name": "lastReplyAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "mirroredFromId": { + "name": "mirroredFromId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quotedMessageId": { + "name": "quotedMessageId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "direct_messages_conversation_id_idx": { + "name": "direct_messages_conversation_id_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_sender_id_idx": { + "name": "direct_messages_sender_id_idx", + "columns": [ + { + "expression": "senderId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_created_at_idx": { + "name": "direct_messages_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_file_id_idx": { + "name": "direct_messages_file_id_idx", + "columns": [ + { + "expression": "fileId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_conversation_active_created_idx": { + "name": "direct_messages_conversation_active_created_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isActive", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_inactive_deleted_at_idx": { + "name": "direct_messages_inactive_deleted_at_idx", + "columns": [ + { + "expression": "isActive", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deletedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_conversation_created_idx": { + "name": "direct_messages_conversation_created_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_conversation_is_read_idx": { + "name": "direct_messages_conversation_is_read_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isRead", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_unread_count_idx": { + "name": "direct_messages_unread_count_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "senderId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isRead", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_parent_created_idx": { + "name": "direct_messages_parent_created_idx", + "columns": [ + { + "expression": "parentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_quoted_id_idx": { + "name": "direct_messages_quoted_id_idx", + "columns": [ + { + "expression": "quotedMessageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "direct_messages_conversationId_dm_conversations_id_fk": { + "name": "direct_messages_conversationId_dm_conversations_id_fk", + "tableFrom": "direct_messages", + "tableTo": "dm_conversations", + "columnsFrom": [ + "conversationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "direct_messages_senderId_users_id_fk": { + "name": "direct_messages_senderId_users_id_fk", + "tableFrom": "direct_messages", + "tableTo": "users", + "columnsFrom": [ + "senderId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "direct_messages_fileId_files_id_fk": { + "name": "direct_messages_fileId_files_id_fk", + "tableFrom": "direct_messages", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "direct_messages_parentId_direct_messages_id_fk": { + "name": "direct_messages_parentId_direct_messages_id_fk", + "tableFrom": "direct_messages", + "tableTo": "direct_messages", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "direct_messages_mirroredFromId_direct_messages_id_fk": { + "name": "direct_messages_mirroredFromId_direct_messages_id_fk", + "tableFrom": "direct_messages", + "tableTo": "direct_messages", + "columnsFrom": [ + "mirroredFromId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "direct_messages_quotedMessageId_direct_messages_id_fk": { + "name": "direct_messages_quotedMessageId_direct_messages_id_fk", + "tableFrom": "direct_messages", + "tableTo": "direct_messages", + "columnsFrom": [ + "quotedMessageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.dm_conversations": { + "name": "dm_conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "participant1Id": { + "name": "participant1Id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "participant2Id": { + "name": "participant2Id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastMessageAt": { + "name": "lastMessageAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastMessagePreview": { + "name": "lastMessagePreview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "participant1LastRead": { + "name": "participant1LastRead", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "participant2LastRead": { + "name": "participant2LastRead", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "dm_conversations_participant1_id_idx": { + "name": "dm_conversations_participant1_id_idx", + "columns": [ + { + "expression": "participant1Id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dm_conversations_participant2_id_idx": { + "name": "dm_conversations_participant2_id_idx", + "columns": [ + { + "expression": "participant2Id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dm_conversations_last_message_at_idx": { + "name": "dm_conversations_last_message_at_idx", + "columns": [ + { + "expression": "lastMessageAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dm_conversations_participant1_last_message_idx": { + "name": "dm_conversations_participant1_last_message_idx", + "columns": [ + { + "expression": "participant1Id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lastMessageAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dm_conversations_participant2_last_message_idx": { + "name": "dm_conversations_participant2_last_message_idx", + "columns": [ + { + "expression": "participant2Id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lastMessageAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dm_conversations_participant1Id_users_id_fk": { + "name": "dm_conversations_participant1Id_users_id_fk", + "tableFrom": "dm_conversations", + "tableTo": "users", + "columnsFrom": [ + "participant1Id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dm_conversations_participant2Id_users_id_fk": { + "name": "dm_conversations_participant2Id_users_id_fk", + "tableFrom": "dm_conversations", + "tableTo": "users", + "columnsFrom": [ + "participant2Id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dm_conversations_participant_pair_key": { + "name": "dm_conversations_participant_pair_key", + "nullsNotDistinct": false, + "columns": [ + "participant1Id", + "participant2Id" + ] + } + } + }, + "public.dm_message_reactions": { + "name": "dm_message_reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "messageId": { + "name": "messageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dm_unique_reaction_idx": { + "name": "dm_unique_reaction_idx", + "columns": [ + { + "expression": "messageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "emoji", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dm_reaction_message_idx": { + "name": "dm_reaction_message_idx", + "columns": [ + { + "expression": "messageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dm_message_reactions_messageId_direct_messages_id_fk": { + "name": "dm_message_reactions_messageId_direct_messages_id_fk", + "tableFrom": "dm_message_reactions", + "tableTo": "direct_messages", + "columnsFrom": [ + "messageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dm_message_reactions_userId_users_id_fk": { + "name": "dm_message_reactions_userId_users_id_fk", + "tableFrom": "dm_message_reactions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.dm_thread_followers": { + "name": "dm_thread_followers", + "schema": "", + "columns": { + "rootMessageId": { + "name": "rootMessageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dm_thread_followers_user_id_idx": { + "name": "dm_thread_followers_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dm_thread_followers_rootMessageId_direct_messages_id_fk": { + "name": "dm_thread_followers_rootMessageId_direct_messages_id_fk", + "tableFrom": "dm_thread_followers", + "tableTo": "direct_messages", + "columnsFrom": [ + "rootMessageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dm_thread_followers_userId_users_id_fk": { + "name": "dm_thread_followers_userId_users_id_fk", + "tableFrom": "dm_thread_followers", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dm_thread_followers_rootMessageId_userId_pk": { + "name": "dm_thread_followers_rootMessageId_userId_pk", + "columns": [ + "rootMessageId", + "userId" + ] + } + }, + "uniqueConstraints": {} + }, + "public.stripe_events": { + "name": "stripe_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processedAt": { + "name": "processedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "stripe_events_type_idx": { + "name": "stripe_events_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_events_processed_at_idx": { + "name": "stripe_events_processed_at_idx", + "columns": [ + { + "expression": "processedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripePriceId": { + "name": "stripePriceId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currentPeriodStart": { + "name": "currentPeriodStart", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "currentPeriodEnd": { + "name": "currentPeriodEnd", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "gifted": { + "name": "gifted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeScheduleId": { + "name": "stripeScheduleId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduledPriceId": { + "name": "scheduledPriceId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduledChangeDate": { + "name": "scheduledChangeDate", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscriptions_user_id_idx": { + "name": "subscriptions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_stripe_subscription_id_idx": { + "name": "subscriptions_stripe_subscription_id_idx", + "columns": [ + { + "expression": "stripeSubscriptionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_stripe_schedule_id_idx": { + "name": "subscriptions_stripe_schedule_id_idx", + "columns": [ + { + "expression": "stripeScheduleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_userId_users_id_fk": { + "name": "subscriptions_userId_users_id_fk", + "tableFrom": "subscriptions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_stripeSubscriptionId_unique": { + "name": "subscriptions_stripeSubscriptionId_unique", + "nullsNotDistinct": false, + "columns": [ + "stripeSubscriptionId" + ] + } + } + }, + "public.contact_submissions": { + "name": "contact_submissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolvedAt": { + "name": "resolvedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "contact_submissions_email_idx": { + "name": "contact_submissions_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_submissions_created_at_idx": { + "name": "contact_submissions_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.feedback_submissions": { + "name": "feedback_submissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "page_url": { + "name": "page_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "screen_size": { + "name": "screen_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "viewport_size": { + "name": "viewport_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "console_errors": { + "name": "console_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feedback_submissions_user_id_idx": { + "name": "feedback_submissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_submissions_status_idx": { + "name": "feedback_submissions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_submissions_created_at_idx": { + "name": "feedback_submissions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_submissions_user_id_users_id_fk": { + "name": "feedback_submissions_user_id_users_id_fk", + "tableFrom": "feedback_submissions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.file_conversations": { + "name": "file_conversations", + "schema": "", + "columns": { + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversationId": { + "name": "conversationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linkedBy": { + "name": "linkedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedAt": { + "name": "linkedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "linkSource": { + "name": "linkSource", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "file_conversations_file_id_idx": { + "name": "file_conversations_file_id_idx", + "columns": [ + { + "expression": "fileId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_conversations_conversation_id_idx": { + "name": "file_conversations_conversation_id_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_conversations_fileId_files_id_fk": { + "name": "file_conversations_fileId_files_id_fk", + "tableFrom": "file_conversations", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_conversations_conversationId_dm_conversations_id_fk": { + "name": "file_conversations_conversationId_dm_conversations_id_fk", + "tableFrom": "file_conversations", + "tableTo": "dm_conversations", + "columnsFrom": [ + "conversationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_conversations_linkedBy_users_id_fk": { + "name": "file_conversations_linkedBy_users_id_fk", + "tableFrom": "file_conversations", + "tableTo": "users", + "columnsFrom": [ + "linkedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_conversations_fileId_conversationId_pk": { + "name": "file_conversations_fileId_conversationId_pk", + "columns": [ + "fileId", + "conversationId" + ] + } + }, + "uniqueConstraints": {} + }, + "public.file_pages": { + "name": "file_pages", + "schema": "", + "columns": { + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linkedBy": { + "name": "linkedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedAt": { + "name": "linkedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "linkSource": { + "name": "linkSource", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "file_pages_file_id_idx": { + "name": "file_pages_file_id_idx", + "columns": [ + { + "expression": "fileId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_pages_page_id_idx": { + "name": "file_pages_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_pages_fileId_files_id_fk": { + "name": "file_pages_fileId_files_id_fk", + "tableFrom": "file_pages", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_pages_pageId_pages_id_fk": { + "name": "file_pages_pageId_pages_id_fk", + "tableFrom": "file_pages", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_pages_linkedBy_users_id_fk": { + "name": "file_pages_linkedBy_users_id_fk", + "tableFrom": "file_pages", + "tableTo": "users", + "columnsFrom": [ + "linkedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_pages_fileId_pageId_pk": { + "name": "file_pages_fileId_pageId_pk", + "columns": [ + "fileId", + "pageId" + ] + } + }, + "uniqueConstraints": {} + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sizeBytes": { + "name": "sizeBytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "mimeType": { + "name": "mimeType", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storagePath": { + "name": "storagePath", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checksumVersion": { + "name": "checksumVersion", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "createdBy": { + "name": "createdBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastAccessedAt": { + "name": "lastAccessedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "files_drive_id_idx": { + "name": "files_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "files_driveId_drives_id_fk": { + "name": "files_driveId_drives_id_fk", + "tableFrom": "files", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "files_createdBy_users_id_fk": { + "name": "files_createdBy_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.task_assignees": { + "name": "task_assignees", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "taskId": { + "name": "taskId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentPageId": { + "name": "agentPageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_assignees_task_id_idx": { + "name": "task_assignees_task_id_idx", + "columns": [ + { + "expression": "taskId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_assignees_user_id_idx": { + "name": "task_assignees_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_assignees_agent_page_id_idx": { + "name": "task_assignees_agent_page_id_idx", + "columns": [ + { + "expression": "agentPageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_assignees_taskId_task_items_id_fk": { + "name": "task_assignees_taskId_task_items_id_fk", + "tableFrom": "task_assignees", + "tableTo": "task_items", + "columnsFrom": [ + "taskId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_assignees_userId_users_id_fk": { + "name": "task_assignees_userId_users_id_fk", + "tableFrom": "task_assignees", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_assignees_agentPageId_pages_id_fk": { + "name": "task_assignees_agentPageId_pages_id_fk", + "tableFrom": "task_assignees", + "tableTo": "pages", + "columnsFrom": [ + "agentPageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_assignees_task_user": { + "name": "task_assignees_task_user", + "nullsNotDistinct": false, + "columns": [ + "taskId", + "userId" + ] + }, + "task_assignees_task_agent": { + "name": "task_assignees_task_agent", + "nullsNotDistinct": false, + "columns": [ + "taskId", + "agentPageId" + ] + } + } + }, + "public.task_items": { + "name": "task_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigneeId": { + "name": "assigneeId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigneeAgentId": { + "name": "assigneeAgentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dueDate": { + "name": "dueDate", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_items_assignee_id_idx": { + "name": "task_items_assignee_id_idx", + "columns": [ + { + "expression": "assigneeId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_items_assignee_agent_id_idx": { + "name": "task_items_assignee_agent_id_idx", + "columns": [ + { + "expression": "assigneeAgentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_items_page_id_idx": { + "name": "task_items_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_items_due_date_idx": { + "name": "task_items_due_date_idx", + "columns": [ + { + "expression": "dueDate", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_items_userId_users_id_fk": { + "name": "task_items_userId_users_id_fk", + "tableFrom": "task_items", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_items_assigneeId_users_id_fk": { + "name": "task_items_assigneeId_users_id_fk", + "tableFrom": "task_items", + "tableTo": "users", + "columnsFrom": [ + "assigneeId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_items_assigneeAgentId_pages_id_fk": { + "name": "task_items_assigneeAgentId_pages_id_fk", + "tableFrom": "task_items", + "tableTo": "pages", + "columnsFrom": [ + "assigneeAgentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_items_pageId_pages_id_fk": { + "name": "task_items_pageId_pages_id_fk", + "tableFrom": "task_items", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_items_pageId_unique": { + "name": "task_items_pageId_unique", + "nullsNotDistinct": false, + "columns": [ + "pageId" + ] + } + } + }, + "public.task_lists": { + "name": "task_lists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conversationId": { + "name": "conversationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_lists_page_id_idx": { + "name": "task_lists_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_lists_conversation_id_idx": { + "name": "task_lists_conversation_id_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_lists_user_id_idx": { + "name": "task_lists_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_lists_userId_users_id_fk": { + "name": "task_lists_userId_users_id_fk", + "tableFrom": "task_lists", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_lists_pageId_pages_id_fk": { + "name": "task_lists_pageId_pages_id_fk", + "tableFrom": "task_lists", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.task_status_configs": { + "name": "task_status_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "taskListId": { + "name": "taskListId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_status_configs_task_list_id_idx": { + "name": "task_status_configs_task_list_id_idx", + "columns": [ + { + "expression": "taskListId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_status_configs_taskListId_task_lists_id_fk": { + "name": "task_status_configs_taskListId_task_lists_id_fk", + "tableFrom": "task_status_configs", + "tableTo": "task_lists", + "columnsFrom": [ + "taskListId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_status_configs_task_list_slug": { + "name": "task_status_configs_task_list_slug", + "nullsNotDistinct": false, + "columns": [ + "taskListId", + "slug" + ] + } + } + }, + "public.security_audit_log": { + "name": "security_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_id": { + "name": "service_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_bidx": { + "name": "ip_bidx", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "geo_location": { + "name": "geo_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "risk_score": { + "name": "risk_score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "anomaly_flags": { + "name": "anomaly_flags", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "chain_seq": { + "name": "chain_seq", + "type": "bigserial", + "primaryKey": false, + "notNull": true + }, + "previous_hash": { + "name": "previous_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_hash": { + "name": "event_hash", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_security_audit_timestamp": { + "name": "idx_security_audit_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_audit_user_timestamp": { + "name": "idx_security_audit_user_timestamp", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_audit_event_type": { + "name": "idx_security_audit_event_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_audit_resource": { + "name": "idx_security_audit_resource", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_audit_ip": { + "name": "idx_security_audit_ip", + "columns": [ + { + "expression": "ip_address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_audit_ip_bidx": { + "name": "idx_security_audit_ip_bidx", + "columns": [ + { + "expression": "ip_bidx", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_audit_event_hash": { + "name": "idx_security_audit_event_hash", + "columns": [ + { + "expression": "event_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_audit_chain_seq": { + "name": "idx_security_audit_chain_seq", + "columns": [ + { + "expression": "chain_seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_audit_risk_score": { + "name": "idx_security_audit_risk_score", + "columns": [ + { + "expression": "risk_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_audit_session": { + "name": "idx_security_audit_session", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_audit_log_user_id_users_id_fk": { + "name": "security_audit_log_user_id_users_id_fk", + "tableFrom": "security_audit_log", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_page_views": { + "name": "user_page_views", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "viewedAt": { + "name": "viewedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_page_views_user_id_idx": { + "name": "user_page_views_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_page_views_page_id_idx": { + "name": "user_page_views_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_page_views_user_page_idx": { + "name": "user_page_views_user_page_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_page_views_userId_users_id_fk": { + "name": "user_page_views_userId_users_id_fk", + "tableFrom": "user_page_views", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_page_views_pageId_pages_id_fk": { + "name": "user_page_views_pageId_pages_id_fk", + "tableFrom": "user_page_views", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_page_views_userId_pageId_pk": { + "name": "user_page_views_userId_pageId_pk", + "columns": [ + "userId", + "pageId" + ] + } + }, + "uniqueConstraints": {} + }, + "public.user_hotkey_preferences": { + "name": "user_hotkey_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hotkeyId": { + "name": "hotkeyId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding": { + "name": "binding", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_hotkey_preferences_user_hotkey_idx": { + "name": "user_hotkey_preferences_user_hotkey_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "hotkeyId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_hotkey_preferences_user_idx": { + "name": "user_hotkey_preferences_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_hotkey_preferences_userId_users_id_fk": { + "name": "user_hotkey_preferences_userId_users_id_fk", + "tableFrom": "user_hotkey_preferences", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.push_notification_tokens": { + "name": "push_notification_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "PushPlatformType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deviceName": { + "name": "deviceName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webPushSubscription": { + "name": "webPushSubscription", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failedAttempts": { + "name": "failedAttempts", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "lastFailedAt": { + "name": "lastFailedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "push_notification_tokens_user_id_idx": { + "name": "push_notification_tokens_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_notification_tokens_token_idx": { + "name": "push_notification_tokens_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_notification_tokens_platform_idx": { + "name": "push_notification_tokens_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_notification_tokens_active_idx": { + "name": "push_notification_tokens_active_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isActive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_notification_tokens_userId_users_id_fk": { + "name": "push_notification_tokens_userId_users_id_fk", + "tableFrom": "push_notification_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.global_assistant_config": { + "name": "global_assistant_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled_user_integrations": { + "name": "enabled_user_integrations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "drive_overrides": { + "name": "drive_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "inherit_drive_integrations": { + "name": "inherit_drive_integrations", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "terminal_access": { + "name": "terminal_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "machines": { + "name": "machines", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "own_machine_page_id": { + "name": "own_machine_page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "global_assistant_config_user_id_users_id_fk": { + "name": "global_assistant_config_user_id_users_id_fk", + "tableFrom": "global_assistant_config", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "global_assistant_config_own_machine_page_id_pages_id_fk": { + "name": "global_assistant_config_own_machine_page_id_pages_id_fk", + "tableFrom": "global_assistant_config", + "tableTo": "pages", + "columnsFrom": [ + "own_machine_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "global_assistant_config_user_id_unique": { + "name": "global_assistant_config_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.integration_audit_log": { + "name": "integration_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_summary": { + "name": "input_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_audit_log_drive_id_idx": { + "name": "integration_audit_log_drive_id_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_audit_log_connection_id_idx": { + "name": "integration_audit_log_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_audit_log_created_at_idx": { + "name": "integration_audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_audit_log_drive_created_at_idx": { + "name": "integration_audit_log_drive_created_at_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_audit_log_drive_id_drives_id_fk": { + "name": "integration_audit_log_drive_id_drives_id_fk", + "tableFrom": "integration_audit_log", + "tableTo": "drives", + "columnsFrom": [ + "drive_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_audit_log_agent_id_pages_id_fk": { + "name": "integration_audit_log_agent_id_pages_id_fk", + "tableFrom": "integration_audit_log", + "tableTo": "pages", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "integration_audit_log_user_id_users_id_fk": { + "name": "integration_audit_log_user_id_users_id_fk", + "tableFrom": "integration_audit_log", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "integration_audit_log_connection_id_integration_connections_id_fk": { + "name": "integration_audit_log_connection_id_integration_connections_id_fk", + "tableFrom": "integration_audit_log", + "tableTo": "integration_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.integration_connections": { + "name": "integration_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integration_connection_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "status_message": { + "name": "status_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credentials": { + "name": "credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "base_url_override": { + "name": "base_url_override", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config_overrides": { + "name": "config_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "account_metadata": { + "name": "account_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "integration_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'owned_drives'" + }, + "oauth_state": { + "name": "oauth_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_health_check": { + "name": "last_health_check", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_connections_provider_id_idx": { + "name": "integration_connections_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connections_user_id_idx": { + "name": "integration_connections_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connections_drive_id_idx": { + "name": "integration_connections_drive_id_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_connections_provider_id_integration_providers_id_fk": { + "name": "integration_connections_provider_id_integration_providers_id_fk", + "tableFrom": "integration_connections", + "tableTo": "integration_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_user_id_users_id_fk": { + "name": "integration_connections_user_id_users_id_fk", + "tableFrom": "integration_connections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_drive_id_drives_id_fk": { + "name": "integration_connections_drive_id_drives_id_fk", + "tableFrom": "integration_connections", + "tableTo": "drives", + "columnsFrom": [ + "drive_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_connected_by_users_id_fk": { + "name": "integration_connections_connected_by_users_id_fk", + "tableFrom": "integration_connections", + "tableTo": "users", + "columnsFrom": [ + "connected_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_connections_user_provider": { + "name": "integration_connections_user_provider", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "provider_id" + ] + }, + "integration_connections_drive_provider": { + "name": "integration_connections_drive_provider", + "nullsNotDistinct": false, + "columns": [ + "drive_id", + "provider_id" + ] + } + } + }, + "public.integration_providers": { + "name": "integration_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "documentation_url": { + "name": "documentation_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "integration_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "openapi_spec": { + "name": "openapi_spec", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_providers_slug_idx": { + "name": "integration_providers_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_providers_drive_id_idx": { + "name": "integration_providers_drive_id_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_providers_created_by_users_id_fk": { + "name": "integration_providers_created_by_users_id_fk", + "tableFrom": "integration_providers", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "integration_providers_drive_id_drives_id_fk": { + "name": "integration_providers_drive_id_drives_id_fk", + "tableFrom": "integration_providers", + "tableTo": "drives", + "columnsFrom": [ + "drive_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_providers_slug_unique": { + "name": "integration_providers_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + } + }, + "public.integration_tool_grants": { + "name": "integration_tool_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "denied_tools": { + "name": "denied_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "read_only": { + "name": "read_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rate_limit_override": { + "name": "rate_limit_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_tool_grants_agent_id_idx": { + "name": "integration_tool_grants_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_tool_grants_connection_id_idx": { + "name": "integration_tool_grants_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_tool_grants_agent_id_pages_id_fk": { + "name": "integration_tool_grants_agent_id_pages_id_fk", + "tableFrom": "integration_tool_grants", + "tableTo": "pages", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_tool_grants_connection_id_integration_connections_id_fk": { + "name": "integration_tool_grants_connection_id_integration_connections_id_fk", + "tableFrom": "integration_tool_grants", + "tableTo": "integration_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_tool_grants_agent_connection": { + "name": "integration_tool_grants_agent_connection", + "nullsNotDistinct": false, + "columns": [ + "agent_id", + "connection_id" + ] + } + } + }, + "public.user_personalization": { + "name": "user_personalization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "writingStyle": { + "name": "writingStyle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rules": { + "name": "rules", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_personalization_user_idx": { + "name": "user_personalization_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_personalization_userId_users_id_fk": { + "name": "user_personalization_userId_users_id_fk", + "tableFrom": "user_personalization", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_automation_preferences": { + "name": "user_automation_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pulseEnabled": { + "name": "pulseEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_automation_preferences_user_idx": { + "name": "user_automation_preferences_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_automation_preferences_userId_users_id_fk": { + "name": "user_automation_preferences_userId_users_id_fk", + "tableFrom": "user_automation_preferences", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.calendar_event_drives": { + "name": "calendar_event_drives", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "eventId": { + "name": "eventId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sharedBy": { + "name": "sharedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sharedAt": { + "name": "sharedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "calendar_event_drives_event_id_idx": { + "name": "calendar_event_drives_event_id_idx", + "columns": [ + { + "expression": "eventId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_event_drives_drive_id_idx": { + "name": "calendar_event_drives_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendar_event_drives_eventId_calendar_events_id_fk": { + "name": "calendar_event_drives_eventId_calendar_events_id_fk", + "tableFrom": "calendar_event_drives", + "tableTo": "calendar_events", + "columnsFrom": [ + "eventId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_event_drives_driveId_drives_id_fk": { + "name": "calendar_event_drives_driveId_drives_id_fk", + "tableFrom": "calendar_event_drives", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_event_drives_sharedBy_users_id_fk": { + "name": "calendar_event_drives_sharedBy_users_id_fk", + "tableFrom": "calendar_event_drives", + "tableTo": "users", + "columnsFrom": [ + "sharedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "calendar_event_drives_event_drive_key": { + "name": "calendar_event_drives_event_drive_key", + "nullsNotDistinct": false, + "columns": [ + "eventId", + "driveId" + ] + } + } + }, + "public.calendar_events": { + "name": "calendar_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdById": { + "name": "createdById", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "startAt": { + "name": "startAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "endAt": { + "name": "endAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "allDay": { + "name": "allDay", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "recurrenceRule": { + "name": "recurrenceRule", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "recurrenceExceptions": { + "name": "recurrenceExceptions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "recurringEventId": { + "name": "recurringEventId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "originalStartAt": { + "name": "originalStartAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "EventVisibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRIVE'" + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "isTrashed": { + "name": "isTrashed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trashedAt": { + "name": "trashedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "googleEventId": { + "name": "googleEventId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "googleCalendarId": { + "name": "googleCalendarId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "syncedFromGoogle": { + "name": "syncedFromGoogle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "lastGoogleSync": { + "name": "lastGoogleSync", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "googleSyncReadOnly": { + "name": "googleSyncReadOnly", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "calendar_events_drive_id_idx": { + "name": "calendar_events_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_events_created_by_id_idx": { + "name": "calendar_events_created_by_id_idx", + "columns": [ + { + "expression": "createdById", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_events_page_id_idx": { + "name": "calendar_events_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_events_start_at_idx": { + "name": "calendar_events_start_at_idx", + "columns": [ + { + "expression": "startAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_events_end_at_idx": { + "name": "calendar_events_end_at_idx", + "columns": [ + { + "expression": "endAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_events_drive_id_start_at_idx": { + "name": "calendar_events_drive_id_start_at_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "startAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_events_recurring_event_id_idx": { + "name": "calendar_events_recurring_event_id_idx", + "columns": [ + { + "expression": "recurringEventId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_events_is_trashed_idx": { + "name": "calendar_events_is_trashed_idx", + "columns": [ + { + "expression": "isTrashed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_events_google_event_id_idx": { + "name": "calendar_events_google_event_id_idx", + "columns": [ + { + "expression": "googleEventId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_events_synced_from_google_idx": { + "name": "calendar_events_synced_from_google_idx", + "columns": [ + { + "expression": "syncedFromGoogle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendar_events_driveId_drives_id_fk": { + "name": "calendar_events_driveId_drives_id_fk", + "tableFrom": "calendar_events", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_events_createdById_users_id_fk": { + "name": "calendar_events_createdById_users_id_fk", + "tableFrom": "calendar_events", + "tableTo": "users", + "columnsFrom": [ + "createdById" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_events_pageId_pages_id_fk": { + "name": "calendar_events_pageId_pages_id_fk", + "tableFrom": "calendar_events", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "calendar_events_google_source_per_user_key": { + "name": "calendar_events_google_source_per_user_key", + "nullsNotDistinct": false, + "columns": [ + "createdById", + "googleCalendarId", + "googleEventId" + ] + } + } + }, + "public.event_attendees": { + "name": "event_attendees", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "eventId": { + "name": "eventId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "AttendeeStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "responseNote": { + "name": "responseNote", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isOrganizer": { + "name": "isOrganizer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isOptional": { + "name": "isOptional", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "invitedAt": { + "name": "invitedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "respondedAt": { + "name": "respondedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "event_attendees_event_id_idx": { + "name": "event_attendees_event_id_idx", + "columns": [ + { + "expression": "eventId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_attendees_user_id_idx": { + "name": "event_attendees_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_attendees_status_idx": { + "name": "event_attendees_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_attendees_user_id_status_idx": { + "name": "event_attendees_user_id_status_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "event_attendees_eventId_calendar_events_id_fk": { + "name": "event_attendees_eventId_calendar_events_id_fk", + "tableFrom": "event_attendees", + "tableTo": "calendar_events", + "columnsFrom": [ + "eventId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_attendees_userId_users_id_fk": { + "name": "event_attendees_userId_users_id_fk", + "tableFrom": "event_attendees", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "event_attendees_event_user_key": { + "name": "event_attendees_event_user_key", + "nullsNotDistinct": false, + "columns": [ + "eventId", + "userId" + ] + } + } + }, + "public.google_calendar_connections": { + "name": "google_calendar_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenExpiresAt": { + "name": "tokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "googleEmail": { + "name": "googleEmail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "googleAccountId": { + "name": "googleAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "GoogleCalendarConnectionStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "statusMessage": { + "name": "statusMessage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "targetDriveId": { + "name": "targetDriveId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "selectedCalendars": { + "name": "selectedCalendars", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "syncFrequencyMinutes": { + "name": "syncFrequencyMinutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "markAsReadOnly": { + "name": "markAsReadOnly", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "lastSyncAt": { + "name": "lastSyncAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lastSyncError": { + "name": "lastSyncError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "syncCursor": { + "name": "syncCursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhookChannels": { + "name": "webhookChannels", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "google_calendar_connections_user_id_idx": { + "name": "google_calendar_connections_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "google_calendar_connections_status_idx": { + "name": "google_calendar_connections_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "google_calendar_connections_target_drive_id_idx": { + "name": "google_calendar_connections_target_drive_id_idx", + "columns": [ + { + "expression": "targetDriveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "google_calendar_connections_userId_users_id_fk": { + "name": "google_calendar_connections_userId_users_id_fk", + "tableFrom": "google_calendar_connections", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "google_calendar_connections_targetDriveId_drives_id_fk": { + "name": "google_calendar_connections_targetDriveId_drives_id_fk", + "tableFrom": "google_calendar_connections", + "tableTo": "drives", + "columnsFrom": [ + "targetDriveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "google_calendar_connections_userId_unique": { + "name": "google_calendar_connections_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + } + }, + "public.calendar_triggers": { + "name": "calendar_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflowId": { + "name": "workflowId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "calendarEventId": { + "name": "calendarEventId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduledById": { + "name": "scheduledById", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "triggerAt": { + "name": "triggerAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "occurrenceDate": { + "name": "occurrenceDate", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "'1970-01-01T00:00:00.000Z'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "calendar_triggers_trigger_at_idx": { + "name": "calendar_triggers_trigger_at_idx", + "columns": [ + { + "expression": "triggerAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_triggers_scheduled_by_idx": { + "name": "calendar_triggers_scheduled_by_idx", + "columns": [ + { + "expression": "scheduledById", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_triggers_calendar_event_idx": { + "name": "calendar_triggers_calendar_event_idx", + "columns": [ + { + "expression": "calendarEventId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_triggers_workflow_id_idx": { + "name": "calendar_triggers_workflow_id_idx", + "columns": [ + { + "expression": "workflowId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendar_triggers_workflowId_workflows_id_fk": { + "name": "calendar_triggers_workflowId_workflows_id_fk", + "tableFrom": "calendar_triggers", + "tableTo": "workflows", + "columnsFrom": [ + "workflowId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_triggers_calendarEventId_calendar_events_id_fk": { + "name": "calendar_triggers_calendarEventId_calendar_events_id_fk", + "tableFrom": "calendar_triggers", + "tableTo": "calendar_events", + "columnsFrom": [ + "calendarEventId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_triggers_driveId_drives_id_fk": { + "name": "calendar_triggers_driveId_drives_id_fk", + "tableFrom": "calendar_triggers", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_triggers_scheduledById_users_id_fk": { + "name": "calendar_triggers_scheduledById_users_id_fk", + "tableFrom": "calendar_triggers", + "tableTo": "users", + "columnsFrom": [ + "scheduledById" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "calendar_triggers_event_occurrence_key": { + "name": "calendar_triggers_event_occurrence_key", + "nullsNotDistinct": false, + "columns": [ + "calendarEventId", + "occurrenceDate" + ] + } + } + }, + "public.workflows": { + "name": "workflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdBy": { + "name": "createdBy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agentPageId": { + "name": "agentPageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contextPageIds": { + "name": "contextPageIds", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "cronExpression": { + "name": "cronExpression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "triggerType": { + "name": "triggerType", + "type": "WorkflowTriggerType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'cron'" + }, + "eventTriggers": { + "name": "eventTriggers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "watchedFolderIds": { + "name": "watchedFolderIds", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "eventDebounceSecs": { + "name": "eventDebounceSecs", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "instructionPageId": { + "name": "instructionPageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isEnabled": { + "name": "isEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "nextRunAt": { + "name": "nextRunAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workflows_drive_id_idx": { + "name": "workflows_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflows_created_by_idx": { + "name": "workflows_created_by_idx", + "columns": [ + { + "expression": "createdBy", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflows_agent_page_id_idx": { + "name": "workflows_agent_page_id_idx", + "columns": [ + { + "expression": "agentPageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflows_enabled_next_run_idx": { + "name": "workflows_enabled_next_run_idx", + "columns": [ + { + "expression": "isEnabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "nextRunAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflows_enabled_trigger_type_idx": { + "name": "workflows_enabled_trigger_type_idx", + "columns": [ + { + "expression": "isEnabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "triggerType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflows_driveId_drives_id_fk": { + "name": "workflows_driveId_drives_id_fk", + "tableFrom": "workflows", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflows_createdBy_users_id_fk": { + "name": "workflows_createdBy_users_id_fk", + "tableFrom": "workflows", + "tableTo": "users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflows_agentPageId_pages_id_fk": { + "name": "workflows_agentPageId_pages_id_fk", + "tableFrom": "workflows", + "tableTo": "pages", + "columnsFrom": [ + "agentPageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflows_instructionPageId_pages_id_fk": { + "name": "workflows_instructionPageId_pages_id_fk", + "tableFrom": "workflows", + "tableTo": "pages", + "columnsFrom": [ + "instructionPageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.workflow_runs": { + "name": "workflow_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflowId": { + "name": "workflowId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sourceTable": { + "name": "sourceTable", + "type": "WorkflowRunSourceTable", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "sourceId": { + "name": "sourceId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "triggerAt": { + "name": "triggerAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "startedAt": { + "name": "startedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "endedAt": { + "name": "endedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "WorkflowRunStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "durationMs": { + "name": "durationMs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "conversationId": { + "name": "conversationId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_runs_workflow_started_at_idx": { + "name": "workflow_runs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflowId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "startedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_runs_source_lookup_idx": { + "name": "workflow_runs_source_lookup_idx", + "columns": [ + { + "expression": "sourceTable", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sourceId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_runs_stuck_run_idx": { + "name": "workflow_runs_stuck_run_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "startedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_runs_running_claim_idx": { + "name": "workflow_runs_running_claim_idx", + "columns": [ + { + "expression": "workflowId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_runs\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_runs_workflowId_workflows_id_fk": { + "name": "workflow_runs_workflowId_workflows_id_fk", + "tableFrom": "workflow_runs", + "tableTo": "workflows", + "columnsFrom": [ + "workflowId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.task_triggers": { + "name": "task_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflowId": { + "name": "workflowId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "taskItemId": { + "name": "taskItemId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "triggerType": { + "name": "triggerType", + "type": "TaskTriggerType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "nextRunAt": { + "name": "nextRunAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastFiredAt": { + "name": "lastFiredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastFireError": { + "name": "lastFireError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isEnabled": { + "name": "isEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "task_triggers_workflow_id_idx": { + "name": "task_triggers_workflow_id_idx", + "columns": [ + { + "expression": "workflowId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_triggers_task_item_id_idx": { + "name": "task_triggers_task_item_id_idx", + "columns": [ + { + "expression": "taskItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_triggers_enabled_next_run_idx": { + "name": "task_triggers_enabled_next_run_idx", + "columns": [ + { + "expression": "isEnabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "nextRunAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_triggers_workflowId_workflows_id_fk": { + "name": "task_triggers_workflowId_workflows_id_fk", + "tableFrom": "task_triggers", + "tableTo": "workflows", + "columnsFrom": [ + "workflowId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_triggers_taskItemId_task_items_id_fk": { + "name": "task_triggers_taskItemId_task_items_id_fk", + "tableFrom": "task_triggers", + "tableTo": "task_items", + "columnsFrom": [ + "taskItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_triggers_task_item_trigger_type_key": { + "name": "task_triggers_task_item_trigger_type_key", + "nullsNotDistinct": false, + "columns": [ + "taskItemId", + "triggerType" + ] + } + } + }, + "public.rate_limit_buckets": { + "name": "rate_limit_buckets", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "rate_limit_buckets_expires_at_idx": { + "name": "rate_limit_buckets_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rate_limit_buckets_key_window_start_pk": { + "name": "rate_limit_buckets_key_window_start_pk", + "columns": [ + "key", + "window_start" + ] + } + }, + "uniqueConstraints": {} + }, + "public.revoked_service_tokens": { + "name": "revoked_service_tokens", + "schema": "", + "columns": { + "jti": { + "name": "jti", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "revoked_service_tokens_expires_at_idx": { + "name": "revoked_service_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.auth_handoff_tokens": { + "name": "auth_handoff_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_handoff_tokens_expires_at_idx": { + "name": "auth_handoff_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_handoff_tokens_kind_expires_at_idx": { + "name": "auth_handoff_tokens_kind_expires_at_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_handoff_tokens_token_hash_kind_pk": { + "name": "auth_handoff_tokens_token_hash_kind_pk", + "columns": [ + "token_hash", + "kind" + ] + } + }, + "uniqueConstraints": {} + }, + "public.pending_invites": { + "name": "pending_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "MemberRole", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "custom_role_id": { + "name": "custom_role_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_invites_drive_id_idx": { + "name": "pending_invites_drive_id_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_invites_email_idx": { + "name": "pending_invites_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_invites_expires_at_idx": { + "name": "pending_invites_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_invites_custom_role_id_idx": { + "name": "pending_invites_custom_role_id_idx", + "columns": [ + { + "expression": "custom_role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_invites_active_drive_email_idx": { + "name": "pending_invites_active_drive_email_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"pending_invites\".\"consumed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_invites_drive_id_drives_id_fk": { + "name": "pending_invites_drive_id_drives_id_fk", + "tableFrom": "pending_invites", + "tableTo": "drives", + "columnsFrom": [ + "drive_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_invites_custom_role_id_drive_roles_id_fk": { + "name": "pending_invites_custom_role_id_drive_roles_id_fk", + "tableFrom": "pending_invites", + "tableTo": "drive_roles", + "columnsFrom": [ + "custom_role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pending_invites_invited_by_users_id_fk": { + "name": "pending_invites_invited_by_users_id_fk", + "tableFrom": "pending_invites", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pending_invites_token_hash_unique": { + "name": "pending_invites_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + } + }, + "public.pending_page_invites": { + "name": "pending_page_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_page_invites_page_id_idx": { + "name": "pending_page_invites_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_page_invites_email_idx": { + "name": "pending_page_invites_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_page_invites_expires_at_idx": { + "name": "pending_page_invites_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_page_invites_active_page_email_idx": { + "name": "pending_page_invites_active_page_email_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"pending_page_invites\".\"consumed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_page_invites_invited_by_users_id_fk": { + "name": "pending_page_invites_invited_by_users_id_fk", + "tableFrom": "pending_page_invites", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_page_invites_page_id_pages_id_fk": { + "name": "pending_page_invites_page_id_pages_id_fk", + "tableFrom": "pending_page_invites", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pending_page_invites_token_hash_unique": { + "name": "pending_page_invites_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + } + }, + "public.pending_connection_invites": { + "name": "pending_connection_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_message": { + "name": "request_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_connection_invites_invited_by_idx": { + "name": "pending_connection_invites_invited_by_idx", + "columns": [ + { + "expression": "invited_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_connection_invites_email_idx": { + "name": "pending_connection_invites_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_connection_invites_expires_at_idx": { + "name": "pending_connection_invites_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_connection_invites_active_inviter_email_idx": { + "name": "pending_connection_invites_active_inviter_email_idx", + "columns": [ + { + "expression": "invited_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"pending_connection_invites\".\"consumed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_connection_invites_invited_by_users_id_fk": { + "name": "pending_connection_invites_invited_by_users_id_fk", + "tableFrom": "pending_connection_invites", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pending_connection_invites_token_hash_unique": { + "name": "pending_connection_invites_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + } + }, + "public.ai_stream_sessions": { + "name": "ai_stream_sessions", + "schema": "", + "columns": { + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Someone'" + }, + "browser_session_id": { + "name": "browser_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'streaming'" + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ai_stream_sessions_channel_status_idx": { + "name": "ai_stream_sessions_channel_status_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.drive_share_links": { + "name": "drive_share_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "MemberRole", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "custom_role_id": { + "name": "custom_role_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "drive_share_links_drive_id_idx": { + "name": "drive_share_links_drive_id_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_share_links_expires_at_idx": { + "name": "drive_share_links_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_share_links_is_active_idx": { + "name": "drive_share_links_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_share_links_custom_role_id_idx": { + "name": "drive_share_links_custom_role_id_idx", + "columns": [ + { + "expression": "custom_role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_share_links_drive_id_drives_id_fk": { + "name": "drive_share_links_drive_id_drives_id_fk", + "tableFrom": "drive_share_links", + "tableTo": "drives", + "columnsFrom": [ + "drive_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "drive_share_links_custom_role_id_drive_roles_id_fk": { + "name": "drive_share_links_custom_role_id_drive_roles_id_fk", + "tableFrom": "drive_share_links", + "tableTo": "drive_roles", + "columnsFrom": [ + "custom_role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "drive_share_links_created_by_users_id_fk": { + "name": "drive_share_links_created_by_users_id_fk", + "tableFrom": "drive_share_links", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "drive_share_links_token_unique": { + "name": "drive_share_links_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + }, + "public.page_share_links": { + "name": "page_share_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "page_share_links_page_id_idx": { + "name": "page_share_links_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_share_links_expires_at_idx": { + "name": "page_share_links_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_share_links_is_active_idx": { + "name": "page_share_links_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "page_share_links_page_id_pages_id_fk": { + "name": "page_share_links_page_id_pages_id_fk", + "tableFrom": "page_share_links", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_share_links_created_by_users_id_fk": { + "name": "page_share_links_created_by_users_id_fk", + "tableFrom": "page_share_links", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "page_share_links_token_unique": { + "name": "page_share_links_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + }, + "public.zoom_connections": { + "name": "zoom_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokenExpiresAt": { + "name": "tokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "zoomUserId": { + "name": "zoomUserId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zoomAccountId": { + "name": "zoomAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zoomEmail": { + "name": "zoomEmail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "ZoomConnectionStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "targetDriveId": { + "name": "targetDriveId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "targetFolderId": { + "name": "targetFolderId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopeVersion": { + "name": "scopeVersion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "includeAiSummary": { + "name": "includeAiSummary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "includeActionItems": { + "name": "includeActionItems", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "includeTranscript": { + "name": "includeTranscript", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "zoom_connections_user_id_idx": { + "name": "zoom_connections_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "zoom_connections_status_idx": { + "name": "zoom_connections_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "zoom_connections_account_id_idx": { + "name": "zoom_connections_account_id_idx", + "columns": [ + { + "expression": "zoomAccountId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "zoom_connections_target_drive_id_idx": { + "name": "zoom_connections_target_drive_id_idx", + "columns": [ + { + "expression": "targetDriveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "zoom_connections_userId_users_id_fk": { + "name": "zoom_connections_userId_users_id_fk", + "tableFrom": "zoom_connections", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "zoom_connections_targetDriveId_drives_id_fk": { + "name": "zoom_connections_targetDriveId_drives_id_fk", + "tableFrom": "zoom_connections", + "tableTo": "drives", + "columnsFrom": [ + "targetDriveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "zoom_connections_userId_unique": { + "name": "zoom_connections_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + } + }, + "public.webhook_triggers": { + "name": "webhook_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflowId": { + "name": "workflowId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connectionId": { + "name": "connectionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "eventType": { + "name": "eventType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isEnabled": { + "name": "isEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "lastFiredAt": { + "name": "lastFiredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastFireError": { + "name": "lastFireError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "webhook_triggers_workflow_id_idx": { + "name": "webhook_triggers_workflow_id_idx", + "columns": [ + { + "expression": "workflowId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_triggers_provider_event_idx": { + "name": "webhook_triggers_provider_event_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "eventType", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isEnabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_triggers_connection_id_idx": { + "name": "webhook_triggers_connection_id_idx", + "columns": [ + { + "expression": "connectionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_triggers_workflowId_workflows_id_fk": { + "name": "webhook_triggers_workflowId_workflows_id_fk", + "tableFrom": "webhook_triggers", + "tableTo": "workflows", + "columnsFrom": [ + "workflowId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_triggers_connectionId_zoom_connections_id_fk": { + "name": "webhook_triggers_connectionId_zoom_connections_id_fk", + "tableFrom": "webhook_triggers", + "tableTo": "zoom_connections", + "columnsFrom": [ + "connectionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_triggers_connection_workflow_event_unique": { + "name": "webhook_triggers_connection_workflow_event_unique", + "nullsNotDistinct": false, + "columns": [ + "connectionId", + "workflowId", + "eventType" + ] + } + } + }, + "public.message_drafts": { + "name": "message_drafts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contextKey": { + "name": "contextKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "message_drafts_user_context_key": { + "name": "message_drafts_user_context_key", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "contextKey", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_drafts_expires_at_idx": { + "name": "message_drafts_expires_at_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_drafts_userId_users_id_fk": { + "name": "message_drafts_userId_users_id_fk", + "tableFrom": "message_drafts", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.terminal_sessions": { + "name": "terminal_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sessionKey": { + "name": "sessionKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandboxId": { + "name": "sandboxId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastActiveAt": { + "name": "lastActiveAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "storageLastBilledAt": { + "name": "storageLastBilledAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "terminal_sessions_page_id_idx": { + "name": "terminal_sessions_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "terminal_sessions_last_active_at_idx": { + "name": "terminal_sessions_last_active_at_idx", + "columns": [ + { + "expression": "lastActiveAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "terminal_sessions_pageId_pages_id_fk": { + "name": "terminal_sessions_pageId_pages_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_userId_users_id_fk": { + "name": "terminal_sessions_userId_users_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "terminal_sessions_sessionKey_unique": { + "name": "terminal_sessions_sessionKey_unique", + "nullsNotDistinct": false, + "columns": [ + "sessionKey" + ] + } + } + }, + "public.published_pages": { + "name": "published_pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artifact_key": { + "name": "artifact_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publish_title": { + "name": "publish_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publish_description": { + "name": "publish_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publish_og_image_url": { + "name": "publish_og_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "noindex": { + "name": "noindex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "published_pages_drive_id_idx": { + "name": "published_pages_drive_id_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "published_pages_page_id_idx": { + "name": "published_pages_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "published_pages_drive_id_drives_id_fk": { + "name": "published_pages_drive_id_drives_id_fk", + "tableFrom": "published_pages", + "tableTo": "drives", + "columnsFrom": [ + "drive_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "published_pages_page_id_pages_id_fk": { + "name": "published_pages_page_id_pages_id_fk", + "tableFrom": "published_pages", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "published_pages_published_by_users_id_fk": { + "name": "published_pages_published_by_users_id_fk", + "tableFrom": "published_pages", + "tableTo": "users", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "published_pages_page_id_key": { + "name": "published_pages_page_id_key", + "nullsNotDistinct": false, + "columns": [ + "page_id" + ] + }, + "published_pages_drive_id_path_key": { + "name": "published_pages_drive_id_path_key", + "nullsNotDistinct": false, + "columns": [ + "drive_id", + "path" + ] + } + } + }, + "public.credit_balances": { + "name": "credit_balances", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "monthlyRemainingCents": { + "name": "monthlyRemainingCents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "monthlyAllowanceCents": { + "name": "monthlyAllowanceCents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "topupRemainingCents": { + "name": "topupRemainingCents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "debtCents": { + "name": "debtCents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pendingMillicents": { + "name": "pendingMillicents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "monthlyPeriodStart": { + "name": "monthlyPeriodStart", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "monthlyPeriodEnd": { + "name": "monthlyPeriodEnd", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "credit_balances_userId_users_id_fk": { + "name": "credit_balances_userId_users_id_fk", + "tableFrom": "credit_balances", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.credit_holds": { + "name": "credit_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "estCents": { + "name": "estCents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "aiUsageLogId": { + "name": "aiUsageLogId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "credit_holds_user_idx": { + "name": "credit_holds_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credit_holds_expires_idx": { + "name": "credit_holds_expires_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credit_holds_userId_users_id_fk": { + "name": "credit_holds_userId_users_id_fk", + "tableFrom": "credit_holds", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.credit_ledger": { + "name": "credit_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entryType": { + "name": "entryType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amountCents": { + "name": "amountCents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "appliedCents": { + "name": "appliedCents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "chargeMillicents": { + "name": "chargeMillicents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "aiUsageLogId": { + "name": "aiUsageLogId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "realCostCents": { + "name": "realCostCents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "markupBps": { + "name": "markupBps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "stripeRef": { + "name": "stripeRef", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumeStatus": { + "name": "consumeStatus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "consumeError": { + "name": "consumeError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconcileGenerationKey": { + "name": "reconcileGenerationKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credit_ledger_user_idx": { + "name": "credit_ledger_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credit_ledger_usage_log_unique": { + "name": "credit_ledger_usage_log_unique", + "columns": [ + { + "expression": "aiUsageLogId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credit_ledger\".\"aiUsageLogId\" IS NOT NULL AND \"credit_ledger\".\"entryType\" = 'usage'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credit_ledger_stripe_ref_unique": { + "name": "credit_ledger_stripe_ref_unique", + "columns": [ + { + "expression": "stripeRef", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credit_ledger\".\"stripeRef\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credit_ledger_reconcile_key_unique": { + "name": "credit_ledger_reconcile_key_unique", + "columns": [ + { + "expression": "reconcileGenerationKey", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credit_ledger\".\"reconcileGenerationKey\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credit_ledger_consume_status_idx": { + "name": "credit_ledger_consume_status_idx", + "columns": [ + { + "expression": "consumeStatus", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credit_ledger_userId_users_id_fk": { + "name": "credit_ledger_userId_users_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.commands": { + "name": "commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry_page_id": { + "name": "entry_page_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'document'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "commands_user_id_idx": { + "name": "commands_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "commands_drive_id_idx": { + "name": "commands_drive_id_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "commands_entry_page_id_idx": { + "name": "commands_entry_page_id_idx", + "columns": [ + { + "expression": "entry_page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "commands_user_id_users_id_fk": { + "name": "commands_user_id_users_id_fk", + "tableFrom": "commands", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "commands_drive_id_drives_id_fk": { + "name": "commands_drive_id_drives_id_fk", + "tableFrom": "commands", + "tableTo": "drives", + "columnsFrom": [ + "drive_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "commands_created_by_id_users_id_fk": { + "name": "commands_created_by_id_users_id_fk", + "tableFrom": "commands", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "commands_entry_page_id_pages_id_fk": { + "name": "commands_entry_page_id_pages_id_fk", + "tableFrom": "commands", + "tableTo": "pages", + "columnsFrom": [ + "entry_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "commands_user_trigger": { + "name": "commands_user_trigger", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "trigger" + ] + }, + "commands_drive_trigger": { + "name": "commands_drive_trigger", + "nullsNotDistinct": false, + "columns": [ + "drive_id", + "trigger" + ] + } + } + }, + "public.conversation_compactions": { + "name": "conversation_compactions", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "summary_tokens": { + "name": "summary_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "compacted_up_to_message_id": { + "name": "compacted_up_to_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compacted_up_to_created_at": { + "name": "compacted_up_to_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "summary_version": { + "name": "summary_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "summarizer_model": { + "name": "summarizer_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_compacted_at": { + "name": "last_compacted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_compactions_page_id_idx": { + "name": "conversation_compactions_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "conversation_compactions_conversation_id_source_pk": { + "name": "conversation_compactions_conversation_id_source_pk", + "columns": [ + "conversation_id", + "source" + ] + } + }, + "uniqueConstraints": {} + }, + "public.custom_domains": { + "name": "custom_domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "custom_domain_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "platform_owned": { + "name": "platform_owned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_domains_hostname_key": { + "name": "custom_domains_hostname_key", + "columns": [ + { + "expression": "hostname", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_domains_drive_id_idx": { + "name": "custom_domains_drive_id_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_domains_primary_per_drive": { + "name": "custom_domains_primary_per_drive", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"custom_domains\".\"is_primary\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_domains_drive_id_drives_id_fk": { + "name": "custom_domains_drive_id_drives_id_fk", + "tableFrom": "custom_domains", + "tableTo": "drives", + "columnsFrom": [ + "drive_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.incidents": { + "name": "incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'detected'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detectedAt": { + "name": "detectedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reportedBy": { + "name": "reportedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "affectedUserCount": { + "name": "affectedUserCount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "affectedScope": { + "name": "affectedScope", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "riskLevel": { + "name": "riskLevel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requiresAuthorityNotification": { + "name": "requiresAuthorityNotification", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "authorityNotificationDeadline": { + "name": "authorityNotificationDeadline", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authorityNotifiedAt": { + "name": "authorityNotifiedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "requiresSubjectNotification": { + "name": "requiresSubjectNotification", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subjectsNotifiedAt": { + "name": "subjectsNotifiedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closedAt": { + "name": "closedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_incidents_status": { + "name": "idx_incidents_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_incidents_detected_at": { + "name": "idx_incidents_detected_at", + "columns": [ + { + "expression": "detectedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_incidents_authority_deadline": { + "name": "idx_incidents_authority_deadline", + "columns": [ + { + "expression": "authorityNotificationDeadline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "incidents_reportedBy_users_id_fk": { + "name": "incidents_reportedBy_users_id_fk", + "tableFrom": "incidents", + "tableTo": "users", + "columnsFrom": [ + "reportedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.data_subject_requests": { + "name": "data_subject_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_email": { + "name": "subject_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_type": { + "name": "request_type", + "type": "data_subject_request_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'erasure'" + }, + "status": { + "name": "status", + "type": "data_subject_request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "force_delete": { + "name": "force_delete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_type": { + "name": "requested_by_type", + "type": "data_subject_requester_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'self'" + }, + "legal_basis": { + "name": "legal_basis", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sla_deadline": { + "name": "sla_deadline", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step_results": { + "name": "step_results", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_subject_requests_status_idx": { + "name": "data_subject_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_subject_requests_sla_deadline_idx": { + "name": "data_subject_requests_sla_deadline_idx", + "columns": [ + { + "expression": "sla_deadline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_subject_requests_user_id_idx": { + "name": "data_subject_requests_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_subject_requests_user_id_users_id_fk": { + "name": "data_subject_requests_user_id_users_id_fk", + "tableFrom": "data_subject_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "data_subject_requests_requested_by_user_id_users_id_fk": { + "name": "data_subject_requests_requested_by_user_id_users_id_fk", + "tableFrom": "data_subject_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenPrefix": { + "name": "tokenPrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "familyId": { + "name": "familyId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clientId": { + "name": "clientId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tokenVersion": { + "name": "tokenVersion", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revokedReason": { + "name": "revokedReason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_access_tokens_family_id_idx": { + "name": "oauth_access_tokens_family_id_idx", + "columns": [ + { + "expression": "familyId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_tokens_user_id_idx": { + "name": "oauth_access_tokens_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_tokens_client_id_idx": { + "name": "oauth_access_tokens_client_id_idx", + "columns": [ + { + "expression": "clientId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_tokens_token_hash_idx": { + "name": "oauth_access_tokens_token_hash_idx", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_tokens_expires_at_idx": { + "name": "oauth_access_tokens_expires_at_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_tokens_clientId_oauth_clients_id_fk": { + "name": "oauth_access_tokens_clientId_oauth_clients_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_userId_users_id_fk": { + "name": "oauth_access_tokens_userId_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_tokens_tokenHash_unique": { + "name": "oauth_access_tokens_tokenHash_unique", + "nullsNotDistinct": false, + "columns": [ + "tokenHash" + ] + } + } + }, + "public.oauth_authorization_codes": { + "name": "oauth_authorization_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "codeHash": { + "name": "codeHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "codePrefix": { + "name": "codePrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clientId": { + "name": "clientId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirectUri": { + "name": "redirectUri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "codeChallengeMethod": { + "name": "codeChallengeMethod", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "issuedFamilyId": { + "name": "issuedFamilyId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_authorization_codes_client_id_idx": { + "name": "oauth_authorization_codes_client_id_idx", + "columns": [ + { + "expression": "clientId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_authorization_codes_user_id_idx": { + "name": "oauth_authorization_codes_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_authorization_codes_code_hash_idx": { + "name": "oauth_authorization_codes_code_hash_idx", + "columns": [ + { + "expression": "codeHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_authorization_codes_expires_at_idx": { + "name": "oauth_authorization_codes_expires_at_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_authorization_codes_clientId_oauth_clients_id_fk": { + "name": "oauth_authorization_codes_clientId_oauth_clients_id_fk", + "tableFrom": "oauth_authorization_codes", + "tableTo": "oauth_clients", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_authorization_codes_userId_users_id_fk": { + "name": "oauth_authorization_codes_userId_users_id_fk", + "tableFrom": "oauth_authorization_codes", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_authorization_codes_codeHash_unique": { + "name": "oauth_authorization_codes_codeHash_unique", + "nullsNotDistinct": false, + "columns": [ + "codeHash" + ] + } + } + }, + "public.oauth_clients": { + "name": "oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "clientId": { + "name": "clientId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clientType": { + "name": "clientType", + "type": "OAuthClientType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "redirectUris": { + "name": "redirectUris", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "isFirstParty": { + "name": "isFirstParty", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "disabledAt": { + "name": "disabledAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_clients_client_id_idx": { + "name": "oauth_clients_client_id_idx", + "columns": [ + { + "expression": "clientId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_clientId_unique": { + "name": "oauth_clients_clientId_unique", + "nullsNotDistinct": false, + "columns": [ + "clientId" + ] + } + } + }, + "public.oauth_device_codes": { + "name": "oauth_device_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "deviceCodeHash": { + "name": "deviceCodeHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deviceCodePrefix": { + "name": "deviceCodePrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userCodeHash": { + "name": "userCodeHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userCodePrefix": { + "name": "userCodePrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clientId": { + "name": "clientId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "approvedAt": { + "name": "approvedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deniedAt": { + "name": "deniedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastPolledAt": { + "name": "lastPolledAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pollIntervalSeconds": { + "name": "pollIntervalSeconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_device_codes_client_id_idx": { + "name": "oauth_device_codes_client_id_idx", + "columns": [ + { + "expression": "clientId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_device_codes_user_id_idx": { + "name": "oauth_device_codes_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_device_codes_device_code_hash_idx": { + "name": "oauth_device_codes_device_code_hash_idx", + "columns": [ + { + "expression": "deviceCodeHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_device_codes_user_code_hash_idx": { + "name": "oauth_device_codes_user_code_hash_idx", + "columns": [ + { + "expression": "userCodeHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_device_codes_expires_at_idx": { + "name": "oauth_device_codes_expires_at_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_device_codes_clientId_oauth_clients_id_fk": { + "name": "oauth_device_codes_clientId_oauth_clients_id_fk", + "tableFrom": "oauth_device_codes", + "tableTo": "oauth_clients", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_device_codes_userId_users_id_fk": { + "name": "oauth_device_codes_userId_users_id_fk", + "tableFrom": "oauth_device_codes", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_device_codes_deviceCodeHash_unique": { + "name": "oauth_device_codes_deviceCodeHash_unique", + "nullsNotDistinct": false, + "columns": [ + "deviceCodeHash" + ] + }, + "oauth_device_codes_userCodeHash_unique": { + "name": "oauth_device_codes_userCodeHash_unique", + "nullsNotDistinct": false, + "columns": [ + "userCodeHash" + ] + } + } + }, + "public.oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenPrefix": { + "name": "tokenPrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "familyId": { + "name": "familyId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clientId": { + "name": "clientId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tokenVersion": { + "name": "tokenVersion", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "familyExpiresAt": { + "name": "familyExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "replacedByTokenId": { + "name": "replacedByTokenId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revokedReason": { + "name": "revokedReason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_refresh_tokens_family_id_idx": { + "name": "oauth_refresh_tokens_family_id_idx", + "columns": [ + { + "expression": "familyId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_tokens_user_id_idx": { + "name": "oauth_refresh_tokens_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_tokens_client_id_idx": { + "name": "oauth_refresh_tokens_client_id_idx", + "columns": [ + { + "expression": "clientId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_tokens_token_hash_idx": { + "name": "oauth_refresh_tokens_token_hash_idx", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_tokens_expires_at_idx": { + "name": "oauth_refresh_tokens_expires_at_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_tokens_clientId_oauth_clients_id_fk": { + "name": "oauth_refresh_tokens_clientId_oauth_clients_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_userId_users_id_fk": { + "name": "oauth_refresh_tokens_userId_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_tokens_tokenHash_unique": { + "name": "oauth_refresh_tokens_tokenHash_unique", + "nullsNotDistinct": false, + "columns": [ + "tokenHash" + ] + } + } + }, + "public.form_targets": { + "name": "form_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sheet:append'" + }, + "canvas_page_id": { + "name": "canvas_page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "header_row": { + "name": "header_row", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "next_row": { + "name": "next_row", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_submitted_at": { + "name": "last_submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "submission_count": { + "name": "submission_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "form_targets_page_id_idx": { + "name": "form_targets_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_targets_drive_id_idx": { + "name": "form_targets_drive_id_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_targets_status_idx": { + "name": "form_targets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_targets_canvas_page_id_idx": { + "name": "form_targets_canvas_page_id_idx", + "columns": [ + { + "expression": "canvas_page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_targets_one_active_per_page_idx": { + "name": "form_targets_one_active_per_page_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"form_targets\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_targets_drive_id_drives_id_fk": { + "name": "form_targets_drive_id_drives_id_fk", + "tableFrom": "form_targets", + "tableTo": "drives", + "columnsFrom": [ + "drive_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_targets_page_id_pages_id_fk": { + "name": "form_targets_page_id_pages_id_fk", + "tableFrom": "form_targets", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_targets_canvas_page_id_pages_id_fk": { + "name": "form_targets_canvas_page_id_pages_id_fk", + "tableFrom": "form_targets", + "tableTo": "pages", + "columnsFrom": [ + "canvas_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "form_targets_created_by_users_id_fk": { + "name": "form_targets_created_by_users_id_fk", + "tableFrom": "form_targets", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "form_targets_token_hash_unique": { + "name": "form_targets_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + } + }, + "public.machine_projects": { + "name": "machine_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "machineId": { + "name": "machineId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repoUrl": { + "name": "repoUrl", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "machine_projects_machine_id_idx": { + "name": "machine_projects_machine_id_idx", + "columns": [ + { + "expression": "machineId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_projects_machine_id_name_idx": { + "name": "machine_projects_machine_id_name_idx", + "columns": [ + { + "expression": "machineId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "machine_projects_ownerId_users_id_fk": { + "name": "machine_projects_ownerId_users_id_fk", + "tableFrom": "machine_projects", + "tableTo": "users", + "columnsFrom": [ + "ownerId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "machine_projects_machineId_pages_id_fk": { + "name": "machine_projects_machineId_pages_id_fk", + "tableFrom": "machine_projects", + "tableTo": "pages", + "columnsFrom": [ + "machineId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.machine_branches": { + "name": "machine_branches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "machineId": { + "name": "machineId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "projectName": { + "name": "projectName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branchName": { + "name": "branchName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sessionKey": { + "name": "sessionKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandboxId": { + "name": "sandboxId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "machine_branches_machine_id_idx": { + "name": "machine_branches_machine_id_idx", + "columns": [ + { + "expression": "machineId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_branches_machine_project_branch_idx": { + "name": "machine_branches_machine_project_branch_idx", + "columns": [ + { + "expression": "machineId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "projectName", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branchName", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "machine_branches_ownerId_users_id_fk": { + "name": "machine_branches_ownerId_users_id_fk", + "tableFrom": "machine_branches", + "tableTo": "users", + "columnsFrom": [ + "ownerId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "machine_branches_machineId_pages_id_fk": { + "name": "machine_branches_machineId_pages_id_fk", + "tableFrom": "machine_branches", + "tableTo": "pages", + "columnsFrom": [ + "machineId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "machine_branches_sessionKey_unique": { + "name": "machine_branches_sessionKey_unique", + "nullsNotDistinct": false, + "columns": [ + "sessionKey" + ] + } + } + }, + "public.machine_agent_terminals": { + "name": "machine_agent_terminals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "machineId": { + "name": "machineId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "projectName": { + "name": "projectName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machineBranchId": { + "name": "machineBranchId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agentType": { + "name": "agentType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "streamSessionId": { + "name": "streamSessionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "machine_agent_terminals_machine_id_idx": { + "name": "machine_agent_terminals_machine_id_idx", + "columns": [ + { + "expression": "machineId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_agent_terminals_branch_id_idx": { + "name": "machine_agent_terminals_branch_id_idx", + "columns": [ + { + "expression": "machineBranchId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_agent_terminals_scope_name_idx": { + "name": "machine_agent_terminals_scope_name_idx", + "columns": [ + { + "expression": "machineId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"projectName\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"machineBranchId\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "machine_agent_terminals_ownerId_users_id_fk": { + "name": "machine_agent_terminals_ownerId_users_id_fk", + "tableFrom": "machine_agent_terminals", + "tableTo": "users", + "columnsFrom": [ + "ownerId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "machine_agent_terminals_machineId_pages_id_fk": { + "name": "machine_agent_terminals_machineId_pages_id_fk", + "tableFrom": "machine_agent_terminals", + "tableTo": "pages", + "columnsFrom": [ + "machineId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "machine_agent_terminals_machineBranchId_machine_branches_id_fk": { + "name": "machine_agent_terminals_machineBranchId_machine_branches_id_fk", + "tableFrom": "machine_agent_terminals", + "tableTo": "machine_branches", + "columnsFrom": [ + "machineBranchId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.AuthProvider": { + "name": "AuthProvider", + "schema": "public", + "values": [ + "email", + "google", + "apple" + ] + }, + "public.PlatformType": { + "name": "PlatformType", + "schema": "public", + "values": [ + "web", + "desktop", + "ios", + "android" + ] + }, + "public.UserRole": { + "name": "UserRole", + "schema": "public", + "values": [ + "user", + "admin" + ] + }, + "public.DriveKind": { + "name": "DriveKind", + "schema": "public", + "values": [ + "STANDARD", + "HOME" + ] + }, + "public.FavoriteItemType": { + "name": "FavoriteItemType", + "schema": "public", + "values": [ + "page", + "drive" + ] + }, + "public.PageType": { + "name": "PageType", + "schema": "public", + "values": [ + "FOLDER", + "DOCUMENT", + "CHANNEL", + "AI_CHAT", + "CANVAS", + "FILE", + "SHEET", + "TASK_LIST", + "CODE", + "MACHINE" + ] + }, + "public.PermissionAction": { + "name": "PermissionAction", + "schema": "public", + "values": [ + "VIEW", + "EDIT", + "SHARE", + "DELETE" + ] + }, + "public.SubjectType": { + "name": "SubjectType", + "schema": "public", + "values": [ + "USER" + ] + }, + "public.MemberRole": { + "name": "MemberRole", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "MEMBER" + ] + }, + "public.pulse_summary_type": { + "name": "pulse_summary_type", + "schema": "public", + "values": [ + "scheduled", + "on_demand", + "welcome" + ] + }, + "public.NotificationType": { + "name": "NotificationType", + "schema": "public", + "values": [ + "PERMISSION_GRANTED", + "PERMISSION_REVOKED", + "PERMISSION_UPDATED", + "PAGE_SHARED", + "DRIVE_INVITED", + "DRIVE_JOINED", + "DRIVE_ROLE_CHANGED", + "CONNECTION_REQUEST", + "CONNECTION_ACCEPTED", + "CONNECTION_REJECTED", + "NEW_DIRECT_MESSAGE", + "EMAIL_VERIFICATION_REQUIRED", + "TOS_PRIVACY_UPDATED", + "MENTION", + "TASK_ASSIGNED" + ] + }, + "public.toast_notification_level": { + "name": "toast_notification_level", + "schema": "public", + "values": [ + "all", + "mentions", + "off" + ] + }, + "public.display_preference_type": { + "name": "display_preference_type", + "schema": "public", + "values": [ + "SHOW_TOKEN_COUNTS", + "SHOW_CODE_TOGGLE", + "DEFAULT_MARKDOWN_MODE" + ] + }, + "public.activity_change_group_type": { + "name": "activity_change_group_type", + "schema": "public", + "values": [ + "user", + "ai", + "automation", + "system" + ] + }, + "public.activity_resource": { + "name": "activity_resource", + "schema": "public", + "values": [ + "page", + "drive", + "permission", + "agent", + "user", + "member", + "role", + "file", + "token", + "device", + "message", + "conversation" + ] + }, + "public.content_format": { + "name": "content_format", + "schema": "public", + "values": [ + "text", + "html", + "json", + "tiptap" + ] + }, + "public.http_method": { + "name": "http_method", + "schema": "public", + "values": [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS" + ] + }, + "public.log_level": { + "name": "log_level", + "schema": "public", + "values": [ + "trace", + "debug", + "info", + "warn", + "error", + "fatal" + ] + }, + "public.drive_backup_schedule_frequency": { + "name": "drive_backup_schedule_frequency", + "schema": "public", + "values": [ + "daily", + "weekly", + "monthly" + ] + }, + "public.drive_backup_source": { + "name": "drive_backup_source", + "schema": "public", + "values": [ + "manual", + "scheduled", + "pre_restore", + "system" + ] + }, + "public.drive_backup_status": { + "name": "drive_backup_status", + "schema": "public", + "values": [ + "pending", + "ready", + "failed" + ] + }, + "public.page_version_source": { + "name": "page_version_source", + "schema": "public", + "values": [ + "manual", + "auto", + "pre_ai", + "pre_restore", + "restore", + "system" + ] + }, + "public.ConnectionStatus": { + "name": "ConnectionStatus", + "schema": "public", + "values": [ + "PENDING", + "ACCEPTED", + "BLOCKED" + ] + }, + "public.PushPlatformType": { + "name": "PushPlatformType", + "schema": "public", + "values": [ + "ios", + "android", + "web" + ] + }, + "public.integration_connection_status": { + "name": "integration_connection_status", + "schema": "public", + "values": [ + "active", + "expired", + "error", + "pending", + "revoked" + ] + }, + "public.integration_provider_type": { + "name": "integration_provider_type", + "schema": "public", + "values": [ + "builtin", + "openapi", + "custom", + "mcp", + "webhook" + ] + }, + "public.integration_visibility": { + "name": "integration_visibility", + "schema": "public", + "values": [ + "private", + "owned_drives", + "all_drives" + ] + }, + "public.AttendeeStatus": { + "name": "AttendeeStatus", + "schema": "public", + "values": [ + "PENDING", + "ACCEPTED", + "DECLINED", + "TENTATIVE" + ] + }, + "public.EventVisibility": { + "name": "EventVisibility", + "schema": "public", + "values": [ + "DRIVE", + "ATTENDEES_ONLY", + "PRIVATE" + ] + }, + "public.GoogleCalendarConnectionStatus": { + "name": "GoogleCalendarConnectionStatus", + "schema": "public", + "values": [ + "active", + "expired", + "error", + "disconnected" + ] + }, + "public.RecurrenceFrequency": { + "name": "RecurrenceFrequency", + "schema": "public", + "values": [ + "DAILY", + "WEEKLY", + "MONTHLY", + "YEARLY" + ] + }, + "public.WorkflowTriggerType": { + "name": "WorkflowTriggerType", + "schema": "public", + "values": [ + "cron", + "event" + ] + }, + "public.WorkflowRunSourceTable": { + "name": "WorkflowRunSourceTable", + "schema": "public", + "values": [ + "taskTriggers", + "calendarTriggers", + "webhookTriggers", + "cron", + "manual" + ] + }, + "public.WorkflowRunStatus": { + "name": "WorkflowRunStatus", + "schema": "public", + "values": [ + "running", + "success", + "error", + "cancelled" + ] + }, + "public.TaskTriggerType": { + "name": "TaskTriggerType", + "schema": "public", + "values": [ + "due_date", + "completion" + ] + }, + "public.ZoomConnectionStatus": { + "name": "ZoomConnectionStatus", + "schema": "public", + "values": [ + "active", + "expired", + "error", + "disconnected" + ] + }, + "public.custom_domain_status": { + "name": "custom_domain_status", + "schema": "public", + "values": [ + "pending", + "verified", + "failed", + "provisioning", + "active", + "dns_failed", + "cert_failed" + ] + }, + "public.data_subject_request_status": { + "name": "data_subject_request_status", + "schema": "public", + "values": [ + "pending", + "queued", + "in_progress", + "blocked", + "completed", + "failed", + "cancelled" + ] + }, + "public.data_subject_request_type": { + "name": "data_subject_request_type", + "schema": "public", + "values": [ + "erasure", + "export", + "access", + "rectification", + "restriction", + "portability", + "objection" + ] + }, + "public.data_subject_requester_type": { + "name": "data_subject_requester_type", + "schema": "public", + "values": [ + "self", + "admin" + ] + }, + "public.OAuthClientType": { + "name": "OAuthClientType", + "schema": "public", + "values": [ + "public", + "confidential" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index a6dce14257..964e16ebe1 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -1373,6 +1373,13 @@ "when": 1783719699261, "tag": "0195_migrate_machine_refs_terminalid_to_machineid", "breakpoints": true + }, + { + "idx": 196, + "version": "7", + "when": 1783722254371, + "tag": "0196_married_blink", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/__tests__/deferred-drop-analytics-tables.test.ts b/packages/db/src/__tests__/deferred-drop-analytics-tables.test.ts new file mode 100644 index 0000000000..9aca701ae0 --- /dev/null +++ b/packages/db/src/__tests__/deferred-drop-analytics-tables.test.ts @@ -0,0 +1,94 @@ +/** + * Pins the DEFERRED drop migration for the 4 analytics PG tables + * (#890 Phase 3 leaf 4 → executed by Phase 6 task kws85p45pvjnivoz3uxs83yp). + * + * The invariants under test: + * - the script is authored but NOT wired into the drizzle migration chain + * (prepared-not-executed — orchestrator ruling: no backfill of pre-cutover + * history exists, so dropping now would lose [start→cutover] analytics); + * - an unconditional RAISE EXCEPTION guard precedes the first DROP, so an + * accidental psql -f cannot destroy the tables; + * - it drops exactly the 4 cutover tables — never error_resolutions, + * ai_usage_logs, or activity_logs (which stay in main PG). + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'fs'; +import path from 'path'; + +const REPO_ROOT = path.resolve(__dirname, '../../../..'); +const SCRIPT_PATH = path.join( + REPO_ROOT, + 'scripts/deferred-migrations/0890-phase6-drop-analytics-pg-tables.sql', +); +const DRIZZLE_DIR = path.resolve(__dirname, '../../drizzle'); +const DROPPED_TABLES = ['api_metrics', 'system_logs', 'user_activities', 'error_logs']; + +/** SQL with line comments stripped, so assertions never match prose. */ +function stripComments(raw: string): string { + return raw + .split('\n') + .filter((line) => !line.trimStart().startsWith('--')) + .join('\n'); +} + +const sql = stripComments(readFileSync(SCRIPT_PATH, 'utf8')); + +describe('deferred analytics drop migration (#890 Phase 3 leaf 4)', () => { + it('given the do-not-run gate, an unconditional RAISE EXCEPTION should precede the first DROP', () => { + const guardIndex = sql.indexOf('RAISE EXCEPTION'); + const firstDropIndex = sql.indexOf('DROP TABLE'); + expect(guardIndex).toBeGreaterThan(-1); + expect(firstDropIndex).toBeGreaterThan(-1); + expect(guardIndex).toBeLessThan(firstDropIndex); + }); + + it('given psql -f autocommit semantics, the guard and ALL drops must live in ONE DO block — separate statements would run the drops anyway (#890 Phase 3 FIX)', () => { + // psql's default ON_ERROR_STOP=off reports a failed statement then + // CONTINUES; a guard that is its own statement blocks nothing. Inside a + // single DO block, RAISE EXCEPTION makes the drops genuinely unreachable. + const doStart = sql.indexOf('DO $$'); + const doEnd = sql.indexOf('$$;', doStart); + expect(doStart).toBeGreaterThan(-1); + expect(doEnd).toBeGreaterThan(doStart); + + const guardIndex = sql.indexOf('RAISE EXCEPTION'); + expect(guardIndex).toBeGreaterThan(doStart); + expect(guardIndex).toBeLessThan(doEnd); + + let dropIndex = sql.indexOf('DROP TABLE'); + expect(dropIndex).toBeGreaterThan(-1); + while (dropIndex !== -1) { + expect(dropIndex, 'every DROP must be inside the guarded DO block').toBeGreaterThan(guardIndex); + expect(dropIndex, 'every DROP must be inside the guarded DO block').toBeLessThan(doEnd); + dropIndex = sql.indexOf('DROP TABLE', dropIndex + 1); + } + + // Exactly one DO block — a second one would reintroduce statement splits. + expect(sql.indexOf('DO $$', doStart + 1)).toBe(-1); + }); + + it('given the cutover set, should drop exactly the 4 analytics tables', () => { + const drops = sql.match(/DROP TABLE IF EXISTS "(\w+)"/g) ?? []; + const dropped = drops.map((d) => /"(\w+)"/.exec(d)?.[1]).sort(); + expect(dropped).toEqual([...DROPPED_TABLES].sort()); + }); + + it('given the tables that STAY in main PG, should never touch them', () => { + for (const kept of ['error_resolutions', 'ai_usage_logs', 'activity_logs']) { + expect(sql).not.toMatch(new RegExp(`DROP TABLE[^;]*"${kept}"`)); + } + }); + + it('given prepared-not-executed, no drizzle migration should drop any of the 4 tables', () => { + const migrationFiles = readdirSync(DRIZZLE_DIR).filter((f) => f.endsWith('.sql')); + expect(migrationFiles.length).toBeGreaterThan(0); + for (const file of migrationFiles) { + const migration = stripComments(readFileSync(path.join(DRIZZLE_DIR, file), 'utf8')); + for (const table of DROPPED_TABLES) { + expect(migration, `${file} must not drop ${table}`).not.toMatch( + new RegExp(`DROP TABLE[^;]*"${table}"`), + ); + } + } + }); +}); diff --git a/packages/db/src/schema/monitoring.ts b/packages/db/src/schema/monitoring.ts index e86081e787..0e1ca45ac2 100644 --- a/packages/db/src/schema/monitoring.ts +++ b/packages/db/src/schema/monitoring.ts @@ -268,6 +268,22 @@ export const errorLogs = pgTable('error_logs', { endpointIdx: index('idx_errors_endpoint').on(table.endpoint), })); +/** + * Error resolution workflow (#890 Phase 3). Post-cutover the error rows + * themselves live in immutable ClickHouse, so the mutable resolved-flag + * workflow is keyed off-row here in main PG by the error row's stable id + * (same id space for CH rows and legacy PG error_logs rows). Readers fetch + * error rows from their store and merge these rows in app code — the two + * stores are never SQL-joined; the resolve action writes only this table. + */ +export const errorResolutions = pgTable('error_resolutions', { + errorId: text('error_id').primaryKey(), + resolved: boolean('resolved').default(true).notNull(), + resolvedAt: timestamp('resolved_at', { mode: 'date' }).defaultNow().notNull(), + resolvedBy: text('resolved_by'), + resolution: text('resolution'), +}); + // `activity_operation` is a text column, not an enum. The column stores values // constrained at the TS layer via the ActivityOperation string-literal union in // packages/lib/src/monitoring/activity-logger.ts. DB-level enum constraint was diff --git a/packages/lib/package.json b/packages/lib/package.json index 5efd80128c..241890f1bb 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -87,6 +87,56 @@ "import": "./dist/logging/ai-usage-purge.js", "require": "./dist/logging/ai-usage-purge.js" }, + "./observability/clickhouse-buffer": { + "types": "./dist/observability/clickhouse-buffer.d.ts", + "import": "./dist/observability/clickhouse-buffer.js", + "require": "./dist/observability/clickhouse-buffer.js" + }, + "./observability/clickhouse-client": { + "types": "./dist/observability/clickhouse-client.d.ts", + "import": "./dist/observability/clickhouse-client.js", + "require": "./dist/observability/clickhouse-client.js" + }, + "./observability/clickhouse-env": { + "types": "./dist/observability/clickhouse-env.d.ts", + "import": "./dist/observability/clickhouse-env.js", + "require": "./dist/observability/clickhouse-env.js" + }, + "./observability/clickhouse-ddl": { + "types": "./dist/observability/clickhouse-ddl.d.ts", + "import": "./dist/observability/clickhouse-ddl.js", + "require": "./dist/observability/clickhouse-ddl.js" + }, + "./observability/analytics-rows": { + "types": "./dist/observability/analytics-rows.d.ts", + "import": "./dist/observability/analytics-rows.js", + "require": "./dist/observability/analytics-rows.js" + }, + "./observability/analytics-inserts": { + "types": "./dist/observability/analytics-inserts.d.ts", + "import": "./dist/observability/analytics-inserts.js", + "require": "./dist/observability/analytics-inserts.js" + }, + "./observability/analytics-read-core": { + "types": "./dist/observability/analytics-read-core.d.ts", + "import": "./dist/observability/analytics-read-core.js", + "require": "./dist/observability/analytics-read-core.js" + }, + "./observability/analytics-reads": { + "types": "./dist/observability/analytics-reads.d.ts", + "import": "./dist/observability/analytics-reads.js", + "require": "./dist/observability/analytics-reads.js" + }, + "./observability/analytics-gdpr": { + "types": "./dist/observability/analytics-gdpr.d.ts", + "import": "./dist/observability/analytics-gdpr.js", + "require": "./dist/observability/analytics-gdpr.js" + }, + "./observability/error-resolutions": { + "types": "./dist/observability/error-resolutions.d.ts", + "import": "./dist/observability/error-resolutions.js", + "require": "./dist/observability/error-resolutions.js" + }, "./billing/credit-pricing": { "types": "./dist/billing/credit-pricing.d.ts", "import": "./dist/billing/credit-pricing.js", @@ -2154,6 +2204,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1045.0", + "@clickhouse/client": "^1.23.1", "@fly/sprites": "0.0.1-rc37", "@iarna/toml": "^2.2.5", "@pagespace/db": "workspace:*", diff --git a/packages/lib/src/compliance/erasure/__tests__/erasure-plan.test.ts b/packages/lib/src/compliance/erasure/__tests__/erasure-plan.test.ts index b8f75084da..c555aa9875 100644 --- a/packages/lib/src/compliance/erasure/__tests__/erasure-plan.test.ts +++ b/packages/lib/src/compliance/erasure/__tests__/erasure-plan.test.ts @@ -7,9 +7,12 @@ import { MAX_ERASURE_ATTEMPTS, } from '../erasure-plan'; +/** CH not in play unless a test says otherwise. */ +const cloudPlan = () => buildErasurePlan({ deploymentMode: 'cloud', clickHouseInPlay: false }); + describe('buildErasurePlan', () => { it('given cloud mode, should include sub-processor propagation steps in a stable order', () => { - const plan = buildErasurePlan({ deploymentMode: 'cloud' }); + const plan = cloudPlan(); const ids = plan.map((s) => s.id); // drive disposition must come before user deletion; user deletion is last-ish. expect(ids.indexOf('drive-disposition')).toBeLessThan(ids.indexOf('delete-user')); @@ -19,7 +22,7 @@ describe('buildErasurePlan', () => { }); it('given on-prem mode, should omit cloud-only sub-processor steps', () => { - const plan = buildErasurePlan({ deploymentMode: 'onprem' }); + const plan = buildErasurePlan({ deploymentMode: 'onprem', clickHouseInPlay: false }); const ids = plan.map((s) => s.id); expect(ids).not.toContain('email-suppression'); expect(ids).not.toContain('ai-provider-erasure'); @@ -30,7 +33,7 @@ describe('buildErasurePlan', () => { }); it('drive disposition is fatal; sub-processor propagation is best-effort', () => { - const plan = buildErasurePlan({ deploymentMode: 'cloud' }); + const plan = cloudPlan(); const drive = plan.find((s) => s.id === 'drive-disposition'); const email = plan.find((s) => s.id === 'email-suppression'); expect(drive?.fatal).toBe(true); @@ -38,16 +41,42 @@ describe('buildErasurePlan', () => { }); it('runs ai-provider-erasure BEFORE purge-ai-usage (manifest reads ai_usage rows)', () => { - const ids = buildErasurePlan({ deploymentMode: 'cloud' }).map((s) => s.id); + const ids = cloudPlan().map((s) => s.id); expect(ids.indexOf('ai-provider-erasure')).toBeLessThan(ids.indexOf('purge-ai-usage')); }); it('every step id is unique', () => { - const plan = buildErasurePlan({ deploymentMode: 'cloud' }); + const plan = cloudPlan(); const ids = plan.map((s) => s.id); expect(new Set(ids).size).toBe(ids.length); }); + describe('purge-monitoring fatality (#890 Phase 3 FIX — CH purge failure must not complete the run)', () => { + it('given ClickHouse NOT in play, purge-monitoring stays best-effort (pre-existing PG-only behavior)', () => { + const step = cloudPlan().find((s) => s.id === 'purge-monitoring'); + expect(step?.fatal).toBe(false); + }); + + it('given ClickHouse in play, purge-monitoring is FATAL — error_logs has no TTL, so a swallowed CH purge failure retains the subject rows forever under a completed run', () => { + const step = buildErasurePlan({ deploymentMode: 'cloud', clickHouseInPlay: true }) + .find((s) => s.id === 'purge-monitoring'); + expect(step?.fatal).toBe(true); + }); + + it('given ClickHouse in play on-prem, purge-monitoring is fatal there too', () => { + const step = buildErasurePlan({ deploymentMode: 'onprem', clickHouseInPlay: true }) + .find((s) => s.id === 'purge-monitoring'); + expect(step?.fatal).toBe(true); + }); + + it('given ClickHouse in play, only purge-monitoring gains fatality — other best-effort steps are unchanged', () => { + const plan = buildErasurePlan({ deploymentMode: 'cloud', clickHouseInPlay: true }); + expect(plan.find((s) => s.id === 'email-suppression')?.fatal).toBe(false); + expect(plan.find((s) => s.id === 'purge-ai-usage')?.fatal).toBe(false); + expect(plan.find((s) => s.id === 'security-audit')?.fatal).toBe(false); + }); + }); + it('exposes the canonical step list', () => { expect(ERASURE_STEPS).toContain('delete-user'); }); diff --git a/packages/lib/src/compliance/erasure/__tests__/run-erasure.test.ts b/packages/lib/src/compliance/erasure/__tests__/run-erasure.test.ts index b9281e654d..becf58b443 100644 --- a/packages/lib/src/compliance/erasure/__tests__/run-erasure.test.ts +++ b/packages/lib/src/compliance/erasure/__tests__/run-erasure.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { runErasure, type RunnableStep, type ErasureRecorder } from '../run-erasure'; +import { buildErasurePlan, classifyErasureError } from '../erasure-plan'; function makeRecorder(): ErasureRecorder & { statuses: string[]; steps: unknown[] } { const statuses: string[] = []; @@ -92,6 +93,43 @@ describe('runErasure', () => { expect(never.run).not.toHaveBeenCalled(); }); + it('given CH in play and the CH purge fails, the run must NOT complete — subject rows would be retained forever under a completed status (#890 Phase 3 FIX)', async () => { + const recorder = makeRecorder(); + // Wire the REAL plan (clickHouseInPlay: true) so this test breaks if the + // fatality decision regresses, not just the runner semantics. + const steps: RunnableStep[] = buildErasurePlan({ + deploymentMode: 'cloud', + clickHouseInPlay: true, + }).map((step) => ({ + id: step.id, + fatal: step.fatal, + run: + step.id === 'purge-monitoring' + ? vi.fn(async () => { + throw new Error('ClickHouse misconfigured: GDPR client unavailable'); + }) + : okStep(step.id).run, + })); + + const result = await runErasure({ + requestId: 'dsr_1', + attemptsSoFar: 0, + steps, + recorder, + now: fixedNow, + }); + + expect(result.status).toBe('failed'); + expect(result.failedStep).toBe('purge-monitoring'); + expect(recorder.statuses).not.toContain('completed'); + // delete-user comes after purge-monitoring: the user row must survive so + // the durable queue can retry the whole run. + const ranSteps = (recorder.steps as Array<{ step: string }>).map((s) => s.step); + expect(ranSteps).not.toContain('delete-user'); + // The failure is retryable — the queue will re-attempt when CH is back. + expect(classifyErasureError(new Error('ClickHouse misconfigured: GDPR client unavailable')).retryable).toBe(true); + }); + it('given a fatal step throws ERASURE_BLOCKED, should mark blocked with a reason and stop', async () => { const recorder = makeRecorder(); const blocked: RunnableStep = { diff --git a/packages/lib/src/compliance/erasure/erasure-plan.ts b/packages/lib/src/compliance/erasure/erasure-plan.ts index 4e799c6afc..fa16ea7bc9 100644 --- a/packages/lib/src/compliance/erasure/erasure-plan.ts +++ b/packages/lib/src/compliance/erasure/erasure-plan.ts @@ -54,6 +54,10 @@ const STEP_DEFS: ErasureStep[] = [ { id: 'delete-avatar', fatal: false, cloudOnly: false }, { id: 'log-account-deletion', fatal: false, cloudOnly: false }, { id: 'anonymize-activity-logs', fatal: false, cloudOnly: false }, + // fatal is recomputed in buildErasurePlan: with ClickHouse in play the CH + // analytics store is part of the subject's data (error_logs has no TTL — + // erasure is its ONLY eraser), so a purge failure must abort the run for a + // durable retry, never leave it "completed" with rows retained. { id: 'purge-monitoring', fatal: false, cloudOnly: false }, { id: 'revoke-integrations', fatal: false, cloudOnly: false }, { id: 'email-suppression', fatal: false, cloudOnly: true }, @@ -69,11 +73,19 @@ const STEP_DEFS: ErasureStep[] = [ export interface BuildErasurePlanInput { deploymentMode: DeploymentMode; + /** + * Whether the ClickHouse analytics store is (or could be) holding subject + * rows — callers pass isClickHouseAnalyticsInPlay() from the observability + * client shell. Flag-independent by design: a rollback window still counts. + */ + clickHouseInPlay: boolean; } export function buildErasurePlan(input: BuildErasurePlanInput): ErasureStep[] { const cloudLike = input.deploymentMode === 'cloud' || input.deploymentMode === 'tenant'; - return STEP_DEFS.filter((step) => (step.cloudOnly ? cloudLike : true)); + return STEP_DEFS.filter((step) => (step.cloudOnly ? cloudLike : true)).map((step) => + step.id === 'purge-monitoring' && input.clickHouseInPlay ? { ...step, fatal: true } : step, + ); } // --------------------------------------------------------------------------- diff --git a/packages/lib/src/compliance/export/gdpr-export.test.ts b/packages/lib/src/compliance/export/gdpr-export.test.ts index 807c31aa59..8575b1295c 100644 --- a/packages/lib/src/compliance/export/gdpr-export.test.ts +++ b/packages/lib/src/compliance/export/gdpr-export.test.ts @@ -10,7 +10,7 @@ * be tested without reproducing the ORM chain shape. Once that seam * exists, remove the chain mocks and promote these to contract tests. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const { mockTable } = vi.hoisted(() => { const fn = (name: string) => ({ @@ -98,6 +98,15 @@ vi.mock('@pagespace/db/schema/monitoring', () => ({ systemLogs: mockTable('systemLogs'), apiMetrics: mockTable('apiMetrics'), errorLogs: mockTable('errorLogs'), + errorResolutions: mockTable('errorResolutions'), +})); +vi.mock('../../observability/clickhouse-client', () => ({ + getClickHouseGdprClient: vi.fn(() => null), +})); +vi.mock('../../observability/analytics-gdpr', () => ({ + collectChUserSystemLogs: vi.fn(), + collectChUserApiMetrics: vi.fn(), + collectChUserErrorLogs: vi.fn(), })); vi.mock('@pagespace/db/schema/storage', () => ({ files: mockTable('files'), @@ -148,6 +157,12 @@ import { collectUserPersonalization, collectAllUserData, } from './gdpr-export'; +import { getClickHouseGdprClient } from '../../observability/clickhouse-client'; +import { + collectChUserSystemLogs, + collectChUserApiMetrics, + collectChUserErrorLogs, +} from '../../observability/analytics-gdpr'; /** Creates a chain-mock db that resolves .where() calls with queued results */ function createChainDb(whereResults: unknown[][]) { @@ -474,6 +489,116 @@ describe('collectUserErrorLogs', () => { }); }); +describe('analytics collectors with CH configured (union of both stores — #890 Phase 3 leaf 4 + FIX)', () => { + const chClient = { query: vi.fn() }; + + beforeEach(() => { + // getClickHouseGdprClient returns a client whenever CH is configured at + // ALL — including the flag-rollback window (CLICKHOUSE_ENABLED toggled + // off after rows landed in CH). The export union must not depend on the + // write-cutover flag; the flag→client logic is unit-tested in + // clickhouse-client/clickhouse-env. + vi.mocked(getClickHouseGdprClient).mockReturnValue(chClient as never); + vi.mocked(collectChUserSystemLogs).mockResolvedValue([]); + vi.mocked(collectChUserApiMetrics).mockResolvedValue([]); + vi.mocked(collectChUserErrorLogs).mockResolvedValue([]); + }); + + afterEach(() => { + vi.mocked(getClickHouseGdprClient).mockReturnValue(null); + }); + + it('given_aMisconfiguredCh_exportPropagates_theGdprClientAccessorThrow', async () => { + vi.mocked(getClickHouseGdprClient).mockImplementation(() => { + throw new Error('ClickHouse misconfigured: GDPR client unavailable'); + }); + const db = createChainDb([[]]); + + await expect(collectUserSystemLogs(db as never, 'user-1')).rejects.toThrow( + 'ClickHouse misconfigured', + ); + }); + + it('given_rowsInBothStores_systemLogsExportReturnsTheUnion', async () => { + // Writers gate on the same flag, so pre-cutover rows live only in PG and + // post-cutover rows only in CH — the union is complete with no overlap. + const pgLog = { + id: 'pg1', timestamp: new Date('2026-01-01'), level: 'info', message: 'pre-cutover', + category: null, endpoint: null, method: null, duration: null, + }; + const chLog = { + id: 'ch1', timestamp: new Date('2026-07-01'), level: 'warn', message: 'post-cutover', + category: 'api', endpoint: '/api/x', method: 'GET', duration: 3, + }; + vi.mocked(collectChUserSystemLogs).mockResolvedValue([chLog]); + const db = createChainDb([[pgLog]]); + + const result = await collectUserSystemLogs(db as never, 'user-1'); + + expect(collectChUserSystemLogs).toHaveBeenCalledWith(chClient, 'user-1'); + expect(result.map(r => r.id)).toEqual(['pg1', 'ch1']); + }); + + it('given_rowsInBothStores_apiMetricsExportReturnsTheUnion', async () => { + const pgMetric = { + id: 'pg1', timestamp: new Date('2026-01-01'), endpoint: '/a', method: 'GET', + statusCode: 200, duration: 1, requestSize: null, responseSize: null, + }; + const chMetric = { + id: 'ch1', timestamp: new Date('2026-07-01'), endpoint: '/b', method: 'POST', + statusCode: 201, duration: 2, requestSize: 10, responseSize: 20, + }; + vi.mocked(collectChUserApiMetrics).mockResolvedValue([chMetric]); + const db = createChainDb([[pgMetric]]); + + const result = await collectUserApiMetrics(db as never, 'user-1'); + + expect(collectChUserApiMetrics).toHaveBeenCalledWith(chClient, 'user-1'); + expect(result.map(r => r.id)).toEqual(['pg1', 'ch1']); + }); + + it('given_chErrorRows_errorLogsExportAttachesResolvedFromTheErrorResolutionsMiniTable', async () => { + const chError = { + id: 'ch-resolved', timestamp: new Date('2026-07-01'), name: 'TypeError', message: 'boom', + endpoint: null, method: null, file: null, line: null, column: null, + }; + const chUnresolved = { + id: 'ch-open', timestamp: new Date('2026-07-02'), name: 'RangeError', message: 'bad', + endpoint: null, method: null, file: null, line: null, column: null, + }; + vi.mocked(collectChUserErrorLogs).mockResolvedValue([chError, chUnresolved]); + // First where(): PG errorLogs rows; second where(): errorResolutions rows. + const db = createChainDb([[], [{ errorId: 'ch-resolved', resolved: true }]]); + + const result = await collectUserErrorLogs(db as never, 'user-1'); + + expect(result).toHaveLength(2); + expect(result.find(r => r.id === 'ch-resolved')?.resolved).toBe(true); + // No workflow row = never resolved; PG legacy default is false, not null. + expect(result.find(r => r.id === 'ch-open')?.resolved).toBe(false); + }); + + it('given_noChErrorRows_errorLogsExportSkipsTheResolutionsQuery', async () => { + const pgError = { + id: 'pg1', timestamp: new Date('2026-01-01'), name: 'TypeError', message: 'old', + endpoint: null, method: null, file: null, line: null, column: null, resolved: false, + }; + const db = createChainDb([[pgError]]); + + const result = await collectUserErrorLogs(db as never, 'user-1'); + + expect(result.map(r => r.id)).toEqual(['pg1']); + expect(db.where).toHaveBeenCalledTimes(1); + }); + + it('given_aChReadFailure_exportPropagates_neverSilentlyOmitsSubjectData', async () => { + vi.mocked(collectChUserSystemLogs).mockRejectedValue(new Error('CH unreachable')); + const db = createChainDb([[]]); + + await expect(collectUserSystemLogs(db as never, 'user-1')).rejects.toThrow('CH unreachable'); + }); +}); + describe('collectUserAiUsage', () => { it('given_usageExists_returnsLogs', async () => { const usage = { diff --git a/packages/lib/src/compliance/export/gdpr-export.ts b/packages/lib/src/compliance/export/gdpr-export.ts index 385e0a8d51..b475ad16bc 100644 --- a/packages/lib/src/compliance/export/gdpr-export.ts +++ b/packages/lib/src/compliance/export/gdpr-export.ts @@ -2,7 +2,7 @@ import { eq, inArray, or, and, ne } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { users } from '@pagespace/db/schema/auth'; import { drives, pages, chatMessages } from '@pagespace/db/schema/core'; -import { aiUsageLogs, activityLogs, systemLogs, apiMetrics, errorLogs } from '@pagespace/db/schema/monitoring'; +import { aiUsageLogs, activityLogs, systemLogs, apiMetrics, errorLogs, errorResolutions } from '@pagespace/db/schema/monitoring'; import { files, filePages } from '@pagespace/db/schema/storage'; import { driveMembers } from '@pagespace/db/schema/members'; import { channelMessages } from '@pagespace/db/schema/chat'; @@ -14,9 +14,26 @@ import { notifications } from '@pagespace/db/schema/notifications'; import { displayPreferences } from '@pagespace/db/schema/display-preferences'; import { userPersonalization } from '@pagespace/db/schema/personalization'; import { decryptUserRow } from '../../auth/user-repository'; +import { getClickHouseGdprClient } from '../../observability/clickhouse-client'; +import { + collectChUserSystemLogs, + collectChUserApiMetrics, + collectChUserErrorLogs, +} from '../../observability/analytics-gdpr'; type DB = NodePgDatabase>; +/** + * Analytics collectors below export the UNION of both stores. The CH leg + * gates on CH being configured at ALL (getClickHouseGdprClient), not on the + * write-cutover flag — a flag rollback after rows landed in CH must not omit + * them from an Art 15 export (#890 Phase 3). No dedup is needed: at any + * moment the flag routes each new row to exactly one store, and ids are + * per-store cuid2s. CH read failures propagate (including the misconfigured + * throw): an Art 15 export must be complete or fail. + */ +const clickHouseClientOrNull = () => getClickHouseGdprClient(); + export interface UserProfileExport { id: string; name: string; @@ -482,7 +499,9 @@ export async function collectUserSystemLogs(database: DB, userId: string): Promi .from(systemLogs) .where(eq(systemLogs.userId, userId)); - return result; + const client = clickHouseClientOrNull(); + if (!client) return result; + return [...result, ...(await collectChUserSystemLogs(client, userId))]; } export async function collectUserApiMetrics(database: DB, userId: string): Promise { @@ -500,7 +519,9 @@ export async function collectUserApiMetrics(database: DB, userId: string): Promi .from(apiMetrics) .where(eq(apiMetrics.userId, userId)); - return result; + const client = clickHouseClientOrNull(); + if (!client) return result; + return [...result, ...(await collectChUserApiMetrics(client, userId))]; } export async function collectUserErrorLogs(database: DB, userId: string): Promise { @@ -520,7 +541,25 @@ export async function collectUserErrorLogs(database: DB, userId: string): Promis .from(errorLogs) .where(eq(errorLogs.userId, userId)); - return result; + const client = clickHouseClientOrNull(); + if (!client) return result; + + const chRows = await collectChUserErrorLogs(client, userId); + if (chRows.length === 0) return result; + + // CH rows carry no resolution state — the resolved flag lives in the + // error_resolutions mini-table in main PG (cross-store two-step, leaf 3). + const resolutionRows = await database + .select({ errorId: errorResolutions.errorId, resolved: errorResolutions.resolved }) + .from(errorResolutions) + .where(inArray(errorResolutions.errorId, chRows.map((r) => r.id))); + const resolvedById = new Map(resolutionRows.map((r) => [r.errorId, r.resolved])); + + return [ + ...result, + // No workflow row = never resolved (false, matching the PG column default). + ...chRows.map((r) => ({ ...r, resolved: resolvedById.get(r.id) ?? false })), + ]; } export async function collectUserAiUsage(database: DB, userId: string): Promise { diff --git a/packages/lib/src/compliance/retention/monitoring-retention.test.ts b/packages/lib/src/compliance/retention/monitoring-retention.test.ts index 370ce169f2..10e328d16a 100644 --- a/packages/lib/src/compliance/retention/monitoring-retention.test.ts +++ b/packages/lib/src/compliance/retention/monitoring-retention.test.ts @@ -34,6 +34,9 @@ vi.mock('@pagespace/db/schema/monitoring', () => ({ errorLogs: { id: 'errorLogs.id', timestamp: 'errorLogs.timestamp' }, userActivities: { id: 'userActivities.id', timestamp: 'userActivities.timestamp' }, })); +vi.mock('../../observability/clickhouse-client', () => ({ + isClickHouseEnabled: vi.fn(() => false), +})); import { getRetentionConfig, @@ -46,6 +49,7 @@ import { runMonitoringRetentionCleanup, } from './monitoring-retention'; import { apiMetrics, systemLogs, errorLogs, userActivities } from '@pagespace/db/schema/monitoring'; +import { isClickHouseEnabled } from '../../observability/clickhouse-client'; const originalEnv = process.env; @@ -58,6 +62,7 @@ beforeEach(() => { delete process.env.RETENTION_AI_USAGE_LOGS_DAYS; vi.clearAllMocks(); mockReturning.mockResolvedValue([]); + vi.mocked(isClickHouseEnabled).mockReturnValue(false); }); afterEach(() => { @@ -273,4 +278,25 @@ describe('runMonitoringRetentionCleanup', () => { await expect(runMonitoringRetentionCleanup()).rejects.toThrow('disk full'); }); + + it('given_clickhouseEnabled_isANoOpForTheFourAnalyticsTables', async () => { + // Post-cutover, retention for the 4 tables is enforced by CH table TTLs + // (clickhouse-ddl.ts). Running the PG purges too would double-handle; + // the frozen pre-cutover PG rows are removed by the Phase 6 drop. + vi.mocked(isClickHouseEnabled).mockReturnValue(true); + + const results = await runMonitoringRetentionCleanup(); + + expect(results).toEqual([]); + expect(mockDeleteTable).not.toHaveBeenCalled(); + }); + + it('given_clickhouseDisabled_pgPurgesRunExactlyAsBefore', async () => { + vi.mocked(isClickHouseEnabled).mockReturnValue(false); + + const results = await runMonitoringRetentionCleanup(); + + expect(results).toHaveLength(4); + expect(mockDeleteTable).toHaveBeenCalledTimes(4); + }); }); diff --git a/packages/lib/src/compliance/retention/monitoring-retention.ts b/packages/lib/src/compliance/retention/monitoring-retention.ts index 022668db3c..3d94fb94ca 100644 --- a/packages/lib/src/compliance/retention/monitoring-retention.ts +++ b/packages/lib/src/compliance/retention/monitoring-retention.ts @@ -1,6 +1,7 @@ import { lt } from 'drizzle-orm'; import { db } from '@pagespace/db/db'; import { apiMetrics, systemLogs, errorLogs, userActivities } from '@pagespace/db/schema/monitoring'; +import { isClickHouseEnabled } from '../../observability/clickhouse-client'; import type { CleanupResult } from './retention-engine'; const DEFAULT_API_METRICS_DAYS = 90; @@ -87,6 +88,16 @@ export async function cleanupUserActivities(opts: { retentionDays: number }): Pr // (GDPR Art 17(3)(b) legal-obligation justification). export async function runMonitoringRetentionCleanup(): Promise { + // With CLICKHOUSE_ENABLED, retention for these 4 tables is enforced by CH + // table TTLs (clickhouse-ddl.ts: api_metrics 90d, system_logs 30d, + // user_activities 180d; error_logs deliberately none — GDPR erasure + // mutations are its only eraser). The PG copies then hold only frozen + // pre-cutover history, removed wholesale by the deferred Phase 6 drop + // (scripts/deferred-migrations/) — purging them here would double-handle + // retention across stores. aiUsageLogs retention is unaffected (separate + // cron via getAiUsageLogsRetentionDays; the table stays in main PG). + if (isClickHouseEnabled()) return []; + const config = getRetentionConfig(); const results = await Promise.all([ cleanupApiMetrics({ retentionDays: config.apiMetricsDays }), diff --git a/packages/lib/src/config/__tests__/env-validation.test.ts b/packages/lib/src/config/__tests__/env-validation.test.ts index d538eb075f..05441e740c 100644 --- a/packages/lib/src/config/__tests__/env-validation.test.ts +++ b/packages/lib/src/config/__tests__/env-validation.test.ts @@ -75,6 +75,52 @@ describe('env-validation', () => { } }); + it('given CLICKHOUSE_* vars absent, should parse successfully (analytics tier is off by default, #890 Phase 3)', () => { + const env = { + NODE_ENV: 'test', + DATABASE_URL: 'postgresql://user:pass@localhost:5432/db', + }; + + const result = serverEnvSchema.safeParse(env); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.CLICKHOUSE_ENABLED).toBeUndefined(); + } + }); + + it('given a stray CLICKHOUSE_ENABLED value (e.g. "0"), should still parse — the exact-match gate lives in clickhouse-env, not app-wide validation', () => { + const env = { + NODE_ENV: 'test', + DATABASE_URL: 'postgresql://user:pass@localhost:5432/db', + CLICKHOUSE_ENABLED: '0', + }; + + const result = serverEnvSchema.safeParse(env); + + expect(result.success).toBe(true); + }); + + it('given full CLICKHOUSE_* connection config, should parse and pass the values through', () => { + const env = { + NODE_ENV: 'test', + DATABASE_URL: 'postgresql://user:pass@localhost:5432/db', + CLICKHOUSE_ENABLED: 'true', + CLICKHOUSE_HOST: 'https://my-cluster.clickhouse.cloud:8443', + CLICKHOUSE_USER: 'default', + CLICKHOUSE_PASSWORD: 'secret', + CLICKHOUSE_DATABASE: 'pagespace_analytics', + }; + + const result = serverEnvSchema.safeParse(env); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.CLICKHOUSE_HOST).toBe('https://my-cluster.clickhouse.cloud:8443'); + expect(result.data.CLICKHOUSE_DATABASE).toBe('pagespace_analytics'); + } + }); + it('given invalid DATABASE_URL format, should fail validation', () => { const invalidEnv = { DATABASE_URL: 'not-a-valid-url', diff --git a/packages/lib/src/config/env-validation.ts b/packages/lib/src/config/env-validation.ts index cfe7fb0f78..f2dc0d4adc 100644 --- a/packages/lib/src/config/env-validation.ts +++ b/packages/lib/src/config/env-validation.ts @@ -52,6 +52,22 @@ export const serverEnvSchema = z // arm break-glass only on the exact value 'true' (fail-closed otherwise). ADMIN_DB_BREAK_GLASS: z.string().optional(), + // ClickHouse analytics tier (#890 Phase 3) — off by default. Only the + // exact value CLICKHOUSE_ENABLED='true' turns it on (accept any string so + // a stray value never fails app-wide env validation; the exact-match gate + // lives in observability/clickhouse-env.ts). All connection vars are + // optional at the schema level: the three-state fail-fast (off → no CH / + // on+configured → client / on+misconfigured → throw) is enforced by the + // client shell at init, not here, so non-analytics code paths still + // validate env. Credentials are server-side secrets — never NEXT_PUBLIC_, + // placeholders only in .env.example. + CLICKHOUSE_ENABLED: z.string().optional(), + CLICKHOUSE_URL: z.string().optional(), + CLICKHOUSE_HOST: z.string().optional(), + CLICKHOUSE_USER: z.string().optional(), + CLICKHOUSE_PASSWORD: z.string().optional(), + CLICKHOUSE_DATABASE: z.string().optional(), + // CSRF Protection (required in production/development, optional in test) CSRF_SECRET: z.string().optional(), diff --git a/packages/lib/src/logging/__tests__/graceful-shutdown.test.ts b/packages/lib/src/logging/__tests__/graceful-shutdown.test.ts new file mode 100644 index 0000000000..78f785cbf2 --- /dev/null +++ b/packages/lib/src/logging/__tests__/graceful-shutdown.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createShutdownHandler } from '../graceful-shutdown'; + +// #890 Phase 3 FIX: logger.ts's old SIGTERM handler fire-and-forgot flush() +// then called process.exit(0) synchronously — the CH insert buffers (fed by +// writeLogsToDatabase during that flush) were killed before any flush, losing +// up to 500 rows/table on EVERY deploy. The handler must sequence: +// flush logs → drain analytics → exit. + +describe('createShutdownHandler — flush → drain → exit ordering', () => { + it('given a shutdown, should flush the log buffer BEFORE draining analytics (flush feeds rows into the CH buffers), then exit 0', async () => { + const order: string[] = []; + const handler = createShutdownHandler({ + flushLogs: vi.fn(async () => { + order.push('flush'); + }), + drainAnalytics: vi.fn(async () => { + order.push('drain'); + }), + exit: vi.fn((code: number) => { + order.push(`exit:${code}`); + }), + }); + + await handler(); + + expect(order).toEqual(['flush', 'drain', 'exit:0']); + }); + + it('given the log flush fails, should STILL drain analytics and exit', async () => { + const drainAnalytics = vi.fn(async () => {}); + const exit = vi.fn(); + const handler = createShutdownHandler({ + flushLogs: vi.fn(async () => { + throw new Error('db down'); + }), + drainAnalytics, + exit, + }); + + await handler(); + + expect(drainAnalytics).toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(0); + }); + + it('given the analytics drain fails, should still exit (shutdown must never hang)', async () => { + const exit = vi.fn(); + const handler = createShutdownHandler({ + flushLogs: vi.fn(async () => {}), + drainAnalytics: vi.fn(async () => { + throw new Error('CH unreachable'); + }), + exit, + }); + + await handler(); + + expect(exit).toHaveBeenCalledWith(0); + }); + + it('given concurrent invocations (SIGINT then SIGTERM), should run the sequence once', async () => { + const flushLogs = vi.fn(async () => {}); + const exit = vi.fn(); + const handler = createShutdownHandler({ + flushLogs, + drainAnalytics: vi.fn(async () => {}), + exit, + }); + + await Promise.all([handler(), handler()]); + + expect(flushLogs).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/lib/src/logging/__tests__/logger-database-clickhouse-cutover.test.ts b/packages/lib/src/logging/__tests__/logger-database-clickhouse-cutover.test.ts new file mode 100644 index 0000000000..37128db9c9 --- /dev/null +++ b/packages/lib/src/logging/__tests__/logger-database-clickhouse-cutover.test.ts @@ -0,0 +1,176 @@ +/** + * Flag-gated cutover of the 4 analytics tables' writes (#890 Phase 3 leaf 2). + * + * CLICKHOUSE_ENABLED off → PG writes exactly as today (pinned by the sibling + * logger-database.test.ts, which runs with the real flag check and no CH env). + * On → the ClickHouse insert adapters take the row and the main PG is never + * touched for api_metrics / system_logs / user_activities / error_logs. + * ai_usage_logs stays PG in BOTH modes (Phase 4 CDC). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockInsert = vi.hoisted(() => vi.fn()); +const mockValues = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const mockIsClickHouseEnabled = vi.hoisted(() => vi.fn()); +const mockInsertApiMetric = vi.hoisted(() => vi.fn()); +const mockInsertSystemLog = vi.hoisted(() => vi.fn()); +const mockInsertUserActivity = vi.hoisted(() => vi.fn()); +const mockInsertError = vi.hoisted(() => vi.fn()); + +vi.mock('@pagespace/db/db', () => ({ + db: { insert: mockInsert }, +})); +vi.mock('@pagespace/db/schema/monitoring', () => ({ + systemLogs: { tableName: 'system_logs' }, + apiMetrics: { tableName: 'api_metrics' }, + aiUsageLogs: { tableName: 'ai_usage_logs' }, + errorLogs: { tableName: 'error_logs' }, + userActivities: { tableName: 'user_activities' }, +})); +vi.mock('../../deployment-mode', () => ({ + isOnPrem: vi.fn().mockReturnValue(false), +})); +vi.mock('../../observability/clickhouse-client', () => ({ + isClickHouseEnabled: mockIsClickHouseEnabled, +})); +vi.mock('../../observability/analytics-inserts', () => ({ + insertApiMetric: mockInsertApiMetric, + insertSystemLog: mockInsertSystemLog, + insertUserActivity: mockInsertUserActivity, + insertError: mockInsertError, +})); + +import { + writeLogsToDatabase, + writeApiMetrics, + writeAiUsage, + writeUserActivity, + writeError, +} from '../logger-database'; + +beforeEach(() => { + vi.clearAllMocks(); + mockInsert.mockReturnValue({ values: mockValues }); + mockValues.mockResolvedValue(undefined); +}); + +const makeLogEntry = () => ({ + timestamp: '2026-07-10T08:30:15.123Z', + level: 'INFO', + message: 'hello', + hostname: 'h', + pid: 1, +}); + +describe('given CLICKHOUSE_ENABLED off, writers should hit main PG and never the CH adapters', () => { + beforeEach(() => { + mockIsClickHouseEnabled.mockReturnValue(false); + }); + + it('writeLogsToDatabase → db.insert(systemLogs)', async () => { + await writeLogsToDatabase([makeLogEntry()] as Parameters[0]); + expect(mockInsert).toHaveBeenCalledTimes(1); + expect(mockInsertSystemLog).not.toHaveBeenCalled(); + }); + + it('writeApiMetrics → db.insert(apiMetrics)', async () => { + await writeApiMetrics({ endpoint: '/e', method: 'GET', statusCode: 200, duration: 1 }); + expect(mockInsert).toHaveBeenCalledTimes(1); + expect(mockInsertApiMetric).not.toHaveBeenCalled(); + }); + + it('writeUserActivity → db.insert(userActivities)', async () => { + await writeUserActivity({ userId: 'u-1', action: 'a' }); + expect(mockInsert).toHaveBeenCalledTimes(1); + expect(mockInsertUserActivity).not.toHaveBeenCalled(); + }); + + it('writeError → db.insert(errorLogs)', async () => { + await writeError({ name: 'E', message: 'm' }); + expect(mockInsert).toHaveBeenCalledTimes(1); + expect(mockInsertError).not.toHaveBeenCalled(); + }); +}); + +describe('given CLICKHOUSE_ENABLED on, writers should route to the CH adapters and retire the PG write', () => { + beforeEach(() => { + mockIsClickHouseEnabled.mockReturnValue(true); + }); + + it('writeLogsToDatabase → insertSystemLog per converted entry, no db.insert', async () => { + await writeLogsToDatabase([ + makeLogEntry(), + { ...makeLogEntry(), message: 'second', level: 'WARN' }, + ] as Parameters[0]); + + expect(mockInsert).not.toHaveBeenCalled(); + expect(mockInsertSystemLog).toHaveBeenCalledTimes(2); + // Rows go through the same convertToDbFormat extraction as the PG path. + expect(mockInsertSystemLog.mock.calls[0][0]).toMatchObject({ + level: 'info', + message: 'hello', + hostname: 'h', + }); + expect(mockInsertSystemLog.mock.calls[1][0]).toMatchObject({ + level: 'warn', + message: 'second', + }); + expect(mockInsertSystemLog.mock.calls[0][0].timestamp).toBeInstanceOf(Date); + expect(mockInsertSystemLog.mock.calls[0][0].id).toBeTruthy(); + }); + + it('writeLogsToDatabase with an empty array stays a no-op', async () => { + await writeLogsToDatabase([]); + expect(mockInsertSystemLog).not.toHaveBeenCalled(); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it('writeApiMetrics → insertApiMetric with the caller shape, no db.insert', async () => { + const ts = new Date('2026-07-10T00:00:00.000Z'); + await writeApiMetrics({ + endpoint: '/api/x', + method: 'GET', + statusCode: 200, + duration: 42, + userId: 'u-1', + timestamp: ts, + }); + + expect(mockInsert).not.toHaveBeenCalled(); + expect(mockInsertApiMetric).toHaveBeenCalledTimes(1); + expect(mockInsertApiMetric.mock.calls[0][0]).toMatchObject({ + endpoint: '/api/x', + method: 'GET', + statusCode: 200, + duration: 42, + userId: 'u-1', + timestamp: ts, + }); + }); + + it('writeUserActivity → insertUserActivity, no db.insert', async () => { + await writeUserActivity({ userId: 'u-1', action: 'page_view', resource: 'page' }); + expect(mockInsert).not.toHaveBeenCalled(); + expect(mockInsertUserActivity).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'u-1', action: 'page_view', resource: 'page' }), + ); + }); + + it('writeError → insertError, no db.insert', async () => { + await writeError({ name: 'TypeError', message: 'bad', line: 42 }); + expect(mockInsert).not.toHaveBeenCalled(); + expect(mockInsertError).toHaveBeenCalledWith( + expect.objectContaining({ name: 'TypeError', message: 'bad', line: 42 }), + ); + }); + + it('writeAiUsage stays on main PG even when CH is on (Phase 4 CDC, not this leaf)', async () => { + await writeAiUsage({ userId: 'u-1', provider: 'openrouter', model: 'm' }); + expect(mockInsert).toHaveBeenCalledTimes(1); + expect(mockInsertApiMetric).not.toHaveBeenCalled(); + expect(mockInsertSystemLog).not.toHaveBeenCalled(); + expect(mockInsertUserActivity).not.toHaveBeenCalled(); + expect(mockInsertError).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/lib/src/logging/__tests__/logger.test.ts b/packages/lib/src/logging/__tests__/logger.test.ts index e6612797da..26e4376282 100644 --- a/packages/lib/src/logging/__tests__/logger.test.ts +++ b/packages/lib/src/logging/__tests__/logger.test.ts @@ -842,12 +842,17 @@ describe('Logger startFlushTimer signal handlers', () => { // Trigger startFlushTimer to register handlers anyLogger.startFlushTimer(); - // Call the SIGINT handler if registered + // Call the SIGINT handler if registered. Shutdown is now async + // (flush → drain analytics → exit, #890 Phase 3), so wait for the exit + // rather than asserting synchronously — and keep the spies installed + // until it lands so the real process.exit can never fire. if (handlers['SIGINT'] && handlers['SIGINT'].length > 0) { const handler = handlers['SIGINT'][handlers['SIGINT'].length - 1]; handler(); - expect(flushSpy).toHaveBeenCalled(); - expect(exitSpy).toHaveBeenCalledWith(0); + await vi.waitFor(() => { + expect(flushSpy).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); + }); } onSpy.mockRestore(); @@ -870,8 +875,10 @@ describe('Logger startFlushTimer signal handlers', () => { if (handlers['SIGTERM'] && handlers['SIGTERM'].length > 0) { const handler = handlers['SIGTERM'][handlers['SIGTERM'].length - 1]; handler(); - expect(flushSpy).toHaveBeenCalled(); - expect(exitSpy).toHaveBeenCalledWith(0); + await vi.waitFor(() => { + expect(flushSpy).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); + }); } onSpy.mockRestore(); diff --git a/packages/lib/src/logging/__tests__/monitoring-purge.test.ts b/packages/lib/src/logging/__tests__/monitoring-purge.test.ts index 3c9bddd742..3a62568d08 100644 --- a/packages/lib/src/logging/__tests__/monitoring-purge.test.ts +++ b/packages/lib/src/logging/__tests__/monitoring-purge.test.ts @@ -2,33 +2,68 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const mockReturning = vi.hoisted(() => vi.fn().mockResolvedValue([])); const mockWhere = vi.hoisted(() => vi.fn().mockReturnValue({ returning: mockReturning })); +const mockSelectWhere = vi.hoisted(() => vi.fn().mockResolvedValue([])); +const mockFrom = vi.hoisted(() => vi.fn().mockReturnValue({ where: mockSelectWhere })); +const mockUpdateWhere = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const mockSet = vi.hoisted(() => vi.fn().mockReturnValue({ where: mockUpdateWhere })); vi.mock('@pagespace/db/db', () => ({ db: { delete: vi.fn().mockReturnValue({ where: mockWhere }), + select: vi.fn().mockReturnValue({ from: mockFrom }), + update: vi.fn().mockReturnValue({ set: mockSet }), }, })); vi.mock('@pagespace/db/schema/monitoring', () => ({ systemLogs: { id: 'id', userId: 'system_logs.user_id' }, apiMetrics: { id: 'id', userId: 'api_metrics.user_id' }, - errorLogs: { id: 'id', userId: 'error_logs.user_id' }, + errorLogs: { id: 'error_logs.id', userId: 'error_logs.user_id' }, userActivities: { id: 'id', userId: 'user_activities.user_id' }, + errorResolutions: { + errorId: 'error_resolutions.error_id', + resolvedBy: 'error_resolutions.resolved_by', + }, })); vi.mock('@pagespace/db/operators', () => ({ eq: vi.fn((field, value) => ({ type: 'eq', field, value })), + inArray: vi.fn((field, values) => ({ type: 'inArray', field, values })), +})); +vi.mock('../../observability/clickhouse-client', () => ({ + getClickHouseGdprClient: vi.fn(() => null), +})); +vi.mock('../../observability/analytics-gdpr', () => ({ + deleteChUserAnalytics: vi.fn().mockResolvedValue(undefined), + collectChUserErrorIds: vi.fn().mockResolvedValue([]), })); import { deleteMonitoringDataForUser } from '../monitoring-purge'; import { db } from '@pagespace/db/db'; -import { eq } from '@pagespace/db/operators'; -import { systemLogs, apiMetrics, errorLogs, userActivities } from '@pagespace/db/schema/monitoring'; +import { eq, inArray } from '@pagespace/db/operators'; +import { + systemLogs, + apiMetrics, + errorLogs, + userActivities, + errorResolutions, +} from '@pagespace/db/schema/monitoring'; +import { getClickHouseGdprClient } from '../../observability/clickhouse-client'; +import { deleteChUserAnalytics, collectChUserErrorIds } from '../../observability/analytics-gdpr'; describe('deleteMonitoringDataForUser', () => { beforeEach(() => { vi.clearAllMocks(); mockReturning.mockResolvedValue([]); mockWhere.mockReturnValue({ returning: mockReturning }); + mockSelectWhere.mockResolvedValue([]); + mockFrom.mockReturnValue({ where: mockSelectWhere }); + mockUpdateWhere.mockResolvedValue(undefined); + mockSet.mockReturnValue({ where: mockUpdateWhere }); vi.mocked(db.delete).mockReturnValue({ where: mockWhere } as never); + vi.mocked(db.select).mockReturnValue({ from: mockFrom } as never); + vi.mocked(db.update).mockReturnValue({ set: mockSet } as never); + vi.mocked(getClickHouseGdprClient).mockReturnValue(null); + vi.mocked(collectChUserErrorIds).mockResolvedValue([]); + vi.mocked(deleteChUserAnalytics).mockResolvedValue(undefined); }); it('given a userId, should delete from all four monitoring tables', async () => { @@ -38,7 +73,6 @@ describe('deleteMonitoringDataForUser', () => { expect(db.delete).toHaveBeenCalledWith(apiMetrics); expect(db.delete).toHaveBeenCalledWith(errorLogs); expect(db.delete).toHaveBeenCalledWith(userActivities); - expect(db.delete).toHaveBeenCalledTimes(4); }); it('given a userId, should filter by the correct userId for each table', async () => { @@ -64,6 +98,7 @@ describe('deleteMonitoringDataForUser', () => { apiMetrics: 2, errorLogs: 0, userActivities: 1, + errorResolutions: 0, }); }); @@ -72,4 +107,110 @@ describe('deleteMonitoringDataForUser', () => { await expect(deleteMonitoringDataForUser('user-123')).rejects.toThrow('Connection lost'); }); + + it('given no CH config at all (default), should never touch the CH erasure path', async () => { + await deleteMonitoringDataForUser('user-123'); + + expect(deleteChUserAnalytics).not.toHaveBeenCalled(); + expect(collectChUserErrorIds).not.toHaveBeenCalled(); + }); + + describe('error_resolutions cleanup (#890 Phase 3 FIX — orphaned resolution notes)', () => { + it('given the subject has PG error rows, should delete their error_resolutions rows by error id', async () => { + mockSelectWhere.mockResolvedValueOnce([{ id: 'pg-err-1' }, { id: 'pg-err-2' }]); + mockReturning.mockResolvedValue([]); + + await deleteMonitoringDataForUser('user-123'); + + expect(db.delete).toHaveBeenCalledWith(errorResolutions); + expect(inArray).toHaveBeenCalledWith(errorResolutions.errorId, ['pg-err-1', 'pg-err-2']); + }); + + it('given the subject has no error rows in either store, should skip the resolutions delete', async () => { + await deleteMonitoringDataForUser('user-123'); + + expect(db.delete).not.toHaveBeenCalledWith(errorResolutions); + }); + + it('should always null resolvedBy where the subject was the RESOLVER (their id must not survive their erasure)', async () => { + await deleteMonitoringDataForUser('user-123'); + + expect(db.update).toHaveBeenCalledWith(errorResolutions); + expect(mockSet).toHaveBeenCalledWith({ resolvedBy: null }); + expect(eq).toHaveBeenCalledWith(errorResolutions.resolvedBy, 'user-123'); + }); + + it('given resolution rows deleted, should report the count', async () => { + mockSelectWhere.mockResolvedValueOnce([{ id: 'pg-err-1' }]); + // First returning() call is the resolutions delete in execution order. + mockReturning.mockResolvedValueOnce([{ errorId: 'pg-err-1' }]); + + const result = await deleteMonitoringDataForUser('user-123'); + + expect(result.errorResolutions).toBe(1); + }); + }); + + describe('with CH configured (GDPR reaches BOTH stores — flag-independent)', () => { + const chClient = { command: vi.fn() }; + + beforeEach(() => { + // getClickHouseGdprClient returns a client whenever CH is configured at + // all — including the flag-rollback window. The flag logic itself is + // unit-tested in clickhouse-client/clickhouse-env. + vi.mocked(getClickHouseGdprClient).mockReturnValue(chClient as never); + }); + + it('given a userId, should erase from CH AND still delete the PG copies', async () => { + await deleteMonitoringDataForUser('user-789'); + + expect(deleteChUserAnalytics).toHaveBeenCalledWith(chClient, 'user-789'); + expect(db.delete).toHaveBeenCalledWith(systemLogs); + expect(db.delete).toHaveBeenCalledWith(apiMetrics); + expect(db.delete).toHaveBeenCalledWith(errorLogs); + expect(db.delete).toHaveBeenCalledWith(userActivities); + }); + + it('should collect CH error ids and clean their resolutions BEFORE the CH delete destroys the join key', async () => { + vi.mocked(collectChUserErrorIds).mockResolvedValue(['ch-err-1']); + mockReturning.mockResolvedValue([]); + + await deleteMonitoringDataForUser('user-789'); + + expect(inArray).toHaveBeenCalledWith(errorResolutions.errorId, ['ch-err-1']); + const resolutionsDeleteOrder = vi + .mocked(db.delete) + .mock.calls.findIndex(([table]) => table === errorResolutions); + expect(resolutionsDeleteOrder).toBeGreaterThan(-1); + const deleteInvocationOrder = vi.mocked(db.delete).mock.invocationCallOrder[resolutionsDeleteOrder]; + const chDeleteOrder = vi.mocked(deleteChUserAnalytics).mock.invocationCallOrder[0]; + expect(deleteInvocationOrder).toBeLessThan(chDeleteOrder); + }); + + it('should merge PG and CH error ids for the resolutions cleanup', async () => { + mockSelectWhere.mockResolvedValueOnce([{ id: 'pg-err-1' }]); + vi.mocked(collectChUserErrorIds).mockResolvedValue(['ch-err-1']); + + await deleteMonitoringDataForUser('user-789'); + + expect(inArray).toHaveBeenCalledWith(errorResolutions.errorId, ['pg-err-1', 'ch-err-1']); + }); + + it('given a CH erasure failure, should propagate (Art 17 erasure is fail-closed)', async () => { + vi.mocked(deleteChUserAnalytics).mockRejectedValueOnce(new Error('mutation rejected')); + + await expect(deleteMonitoringDataForUser('user-789')).rejects.toThrow('mutation rejected'); + }); + }); + + it('given a misconfigured CH (getClickHouseGdprClient throws), should propagate rather than skip erasure', async () => { + vi.mocked(getClickHouseGdprClient).mockImplementation(() => { + throw new Error('ClickHouse misconfigured: GDPR client unavailable'); + }); + + await expect(deleteMonitoringDataForUser('user-789')).rejects.toThrow('ClickHouse misconfigured'); + expect(deleteChUserAnalytics).not.toHaveBeenCalled(); + // Nothing was deleted from PG either — the run must be retryable as a whole. + expect(db.delete).not.toHaveBeenCalled(); + }); }); diff --git a/packages/lib/src/logging/graceful-shutdown.ts b/packages/lib/src/logging/graceful-shutdown.ts new file mode 100644 index 0000000000..0e287530a5 --- /dev/null +++ b/packages/lib/src/logging/graceful-shutdown.ts @@ -0,0 +1,46 @@ +/** + * Shutdown sequencing for processes that buffer telemetry (#890 Phase 3 FIX). + * + * The logger's flush feeds rows INTO the ClickHouse insert buffers + * (writeLogsToDatabase → analytics-inserts), so the order is load-bearing: + * flush logs first, then drain the analytics buffers, then exit. The old + * handler fire-and-forgot flush() and called process.exit(0) synchronously — + * losing up to 500 buffered rows per table on every deploy. + * + * Pure core: the sequencing is a factory over injected effects, testable + * without real signals or a real process. + */ + +export interface ShutdownHandlerDeps { + flushLogs: () => Promise; + drainAnalytics: () => Promise; + exit: (code: number) => void; + /** Receives payload-free failure notes; defaults to console.error. */ + logError?: (message: string) => void; +} + +export function createShutdownHandler(deps: ShutdownHandlerDeps): () => Promise { + const logError = deps.logError ?? ((message: string) => console.error(message)); + let running: Promise | null = null; + + const run = async (): Promise => { + try { + await deps.flushLogs(); + } catch (error) { + logError(`[shutdown] log flush failed: ${error instanceof Error ? error.message : String(error)}`); + } + try { + await deps.drainAnalytics(); + } catch (error) { + logError(`[shutdown] analytics drain failed: ${error instanceof Error ? error.message : String(error)}`); + } + deps.exit(0); + }; + + return () => { + // A second signal while the first shutdown is in flight must not restart + // the sequence (or exit before the drain completes). + if (!running) running = run(); + return running; + }; +} diff --git a/packages/lib/src/logging/logger-database.ts b/packages/lib/src/logging/logger-database.ts index eadfd533c8..e933604e3e 100644 --- a/packages/lib/src/logging/logger-database.ts +++ b/packages/lib/src/logging/logger-database.ts @@ -9,6 +9,21 @@ import type { LogEntry, LogContext } from './logger'; import type { LogInput, HttpMethod } from './logger-types'; import { createId } from '@paralleldrive/cuid2'; import { isOnPrem } from '../deployment-mode'; +import { isClickHouseEnabled } from '../observability/clickhouse-client'; +import { + insertApiMetric, + insertError, + insertSystemLog, + insertUserActivity, +} from '../observability/analytics-inserts'; + +// #890 Phase 3: when CLICKHOUSE_ENABLED='true', the 4 analytics tables +// (system_logs, api_metrics, user_activities, error_logs) write to ClickHouse +// via the buffered insert adapters and the main-PG write retires. Off (the +// default) leaves the PG path byte-identical. aiUsageLogs stays PG in both +// modes — billing joins + cost-reconcile UPDATEs (Phase 4 replicates via CDC). +// Readers stay on PG until leaf 3, so enabled mode has a transitional window +// where the admin UI shows only pre-cutover rows. interface DatabaseLogEntry { id: string; @@ -104,6 +119,14 @@ function convertToDbFormat(entry: LogEntry): DatabaseLogEntry { export async function writeLogsToDatabase(entries: LogEntry[]): Promise { if (entries.length === 0) return; + if (isClickHouseEnabled()) { + // Same field extraction as the PG path, then (row) → void into the buffer. + for (const entry of entries) { + insertSystemLog(convertToDbFormat(entry)); + } + return; + } + try { const dbEntries = entries.map(convertToDbFormat); @@ -136,6 +159,11 @@ export async function writeApiMetrics(metrics: { cacheKey?: string; timestamp?: Date; }): Promise { + if (isClickHouseEnabled()) { + insertApiMetric(metrics); + return; + } + try { await db.insert(apiMetrics).values({ id: createId(), @@ -261,6 +289,11 @@ export async function writeUserActivity(activity: { userAgent?: string; metadata?: LogInput; }): Promise { + if (isClickHouseEnabled()) { + insertUserActivity(activity); + return; + } + try { await db.insert(userActivities).values({ id: createId(), @@ -300,6 +333,11 @@ export async function writeError(error: { userAgent?: string; metadata?: LogInput; }): Promise { + if (isClickHouseEnabled()) { + insertError(error); + return; + } + try { await db.insert(errorLogs).values({ id: createId(), diff --git a/packages/lib/src/logging/logger.ts b/packages/lib/src/logging/logger.ts index 7b03f45db9..f2dd0448d9 100644 --- a/packages/lib/src/logging/logger.ts +++ b/packages/lib/src/logging/logger.ts @@ -8,6 +8,7 @@ import { createId } from '@paralleldrive/cuid2'; import type { LogInput } from './logger-types'; import { scrubPII } from '../compliance/pii-scrubber'; import { fireSiemErrorHook, type SiemErrorPayload } from './siem-error-hook'; +import { createShutdownHandler } from './graceful-shutdown'; export enum LogLevel { TRACE = 0, @@ -361,15 +362,26 @@ class Logger { }); }, this.config.flushInterval); - // Ensure flush on process exit + // Ensure flush on process exit. flush() feeds rows into the ClickHouse + // insert buffers (writeLogsToDatabase → analytics-inserts), so shutdown + // must sequence flush → drain → exit; exiting synchronously here loses + // up to 500 buffered CH rows per table on every deploy (#890 Phase 3). process.on('beforeExit', () => this.flush()); + const shutdown = createShutdownHandler({ + flushLogs: () => this.flush(), + drainAnalytics: async () => { + // Dynamic import to keep @clickhouse/client out of this module's + // static graph (same reason writeToDatabase imports lazily). + const { drainAnalyticsInserts } = await import('../observability/analytics-inserts'); + await drainAnalyticsInserts(); + }, + exit: (code) => process.exit(code), + }); process.on('SIGINT', () => { - this.flush(); - process.exit(0); + void shutdown(); }); process.on('SIGTERM', () => { - this.flush(); - process.exit(0); + void shutdown(); }); } diff --git a/packages/lib/src/logging/monitoring-purge.ts b/packages/lib/src/logging/monitoring-purge.ts index 37952ae613..1c6ca08453 100644 --- a/packages/lib/src/logging/monitoring-purge.ts +++ b/packages/lib/src/logging/monitoring-purge.ts @@ -4,20 +4,65 @@ * Deletes user-scoped rows from monitoring tables during account removal. * security_audit_log is intentionally excluded — legal retention requirement * (tamper-evident hash chain must remain intact for compliance). + * + * Erasure must reach BOTH stores wherever subject data COULD live: it gates + * on CH being configured at all (getClickHouseGdprClient), NOT on the + * write-cutover flag — a flag rollback after rows landed in CH must not + * strand them (#890 Phase 3). Both legs are fail-closed — a partial erasure + * must surface to the caller, never report success. + * + * error_resolutions rows (admin resolution notes keyed by error id) are + * cleaned here too: the subject's error ids are collected from both stores + * BEFORE the error rows are deleted, because the CH delete destroys the only + * join key and would leave the notes as unreachable orphans whose free text + * can echo subject PII. A subject who RESOLVED errors also gets their id + * nulled out of resolvedBy. */ import { db } from '@pagespace/db/db'; -import { eq } from '@pagespace/db/operators'; -import { systemLogs, apiMetrics, errorLogs, userActivities } from '@pagespace/db/schema/monitoring'; +import { eq, inArray } from '@pagespace/db/operators'; +import { + systemLogs, + apiMetrics, + errorLogs, + userActivities, + errorResolutions, +} from '@pagespace/db/schema/monitoring'; +import { getClickHouseGdprClient } from '../observability/clickhouse-client'; +import { deleteChUserAnalytics, collectChUserErrorIds } from '../observability/analytics-gdpr'; export interface MonitoringDeleteResult { systemLogs: number; apiMetrics: number; errorLogs: number; userActivities: number; + errorResolutions: number; } export async function deleteMonitoringDataForUser(userId: string): Promise { + // Throws on partial CH config — an eraser that cannot reach a store that + // may hold subject rows must not pretend it erased. + const client = getClickHouseGdprClient(); + + // Join keys first: after the deletes below they no longer exist anywhere. + const pgErrorIdRows = await db + .select({ id: errorLogs.id }) + .from(errorLogs) + .where(eq(errorLogs.userId, userId)); + const chErrorIds = client ? await collectChUserErrorIds(client, userId) : []; + const subjectErrorIds = [...pgErrorIdRows.map((row) => row.id), ...chErrorIds]; + + const resolutionRows = subjectErrorIds.length + ? await db + .delete(errorResolutions) + .where(inArray(errorResolutions.errorId, subjectErrorIds)) + .returning({ errorId: errorResolutions.errorId }) + : []; + await db + .update(errorResolutions) + .set({ resolvedBy: null }) + .where(eq(errorResolutions.resolvedBy, userId)); + const [sysResult, apiResult, errResult, actResult] = await Promise.all([ db.delete(systemLogs).where(eq(systemLogs.userId, userId)).returning({ id: systemLogs.id }), db.delete(apiMetrics).where(eq(apiMetrics.userId, userId)).returning({ id: apiMetrics.id }), @@ -25,10 +70,13 @@ export async function deleteMonitoringDataForUser(userId: string): Promise((resolve) => { + const req = get(`${CH_URL}/ping`, { timeout: 2000 }, (res) => { + res.resume(); + resolve(res.statusCode === 200); + }); + req.on('timeout', () => req.destroy()); + req.on('error', () => resolve(false)); +}); + +describe.skipIf(!reachable)('GDPR round-trip on the CH container (export reads + erasure deletes)', () => { + let client: ClickHouseClient; + const runId = `gdpr-${Math.random().toString(36).slice(2)}`; + const subject = `${runId}-subject`; + const bystander = `${runId}-bystander`; + const ts = toClickHouseDateTime64(new Date(Date.now() - 60 * 60 * 1000)); + + const countByUser = async (table: string, userId: string): Promise => { + const rs = await client.query({ + query: `SELECT count() AS c FROM ${table} WHERE user_id = {userId: String}`, + query_params: { userId }, + format: 'JSONEachRow', + }); + const rows = await rs.json<{ c: string }>(); + return Number(rows[0]?.c ?? 0); + }; + + beforeAll(async () => { + client = createClient({ + url: CH_URL, + username: process.env.CLICKHOUSE_USER ?? 'user', + password: process.env.CLICKHOUSE_PASSWORD ?? 'password', + database: process.env.CLICKHOUSE_DATABASE ?? 'pagespace_analytics', + }); + await ensureAnalyticsTables(client); + + const insert = (table: string, values: Record[]) => + client.insert({ table, values, format: 'JSONEachRow' }); + + for (const userId of [subject, bystander]) { + await Promise.all([ + insert(ANALYTICS_TABLES.systemLogs, [ + { id: `${userId}-sl`, timestamp: ts, level: 'info', message: `log for ${userId}`, user_id: userId }, + ]), + insert(ANALYTICS_TABLES.apiMetrics, [ + { + id: `${userId}-am`, timestamp: ts, endpoint: '/api/gdpr-it', method: 'GET', + status_code: 200, duration: 5, user_id: userId, + }, + ]), + insert(ANALYTICS_TABLES.userActivities, [ + { id: `${userId}-ua`, timestamp: ts, user_id: userId, action: 'gdpr-it' }, + ]), + insert(ANALYTICS_TABLES.errorLogs, [ + { id: `${userId}-el`, timestamp: ts, name: 'ItError', message: `error for ${userId}`, user_id: userId }, + ]), + ]); + } + }); + + afterAll(async () => { + // Remove the bystander's rows too so reruns start clean. + if (client) { + await deleteChUserAnalytics(client, bystander); + await client.close(); + } + }); + + it('given rows from two users, export collectors should return exactly the subject rows', async () => { + const [sysRows, metricRows, errorRows] = await Promise.all([ + collectChUserSystemLogs(client, subject), + collectChUserApiMetrics(client, subject), + collectChUserErrorLogs(client, subject), + ]); + + expect(sysRows.map((r) => r.id)).toEqual([`${subject}-sl`]); + expect(sysRows[0].message).toBe(`log for ${subject}`); + expect(sysRows[0].timestamp).toBeInstanceOf(Date); + expect(sysRows[0].category).toBeNull(); // '' write-mapping round-trips to null + + expect(metricRows.map((r) => r.id)).toEqual([`${subject}-am`]); + expect(metricRows[0].statusCode).toBe(200); + + expect(errorRows.map((r) => r.id)).toEqual([`${subject}-el`]); + expect('resolved' in errorRows[0]).toBe(false); + }); + + it('given an erasure, the subject vanishes from all 4 tables while the bystander survives', async () => { + await deleteChUserAnalytics(client, subject); + + for (const table of Object.values(ANALYTICS_TABLES)) { + expect(await countByUser(table, subject), `${table} subject`).toBe(0); + expect(await countByUser(table, bystander), `${table} bystander`).toBe(1); + } + }); +}); diff --git a/packages/lib/src/observability/__tests__/analytics-gdpr.test.ts b/packages/lib/src/observability/__tests__/analytics-gdpr.test.ts new file mode 100644 index 0000000000..7150146129 --- /dev/null +++ b/packages/lib/src/observability/__tests__/analytics-gdpr.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + buildUserSystemLogsExportQuery, + buildUserApiMetricsExportQuery, + buildUserErrorLogsExportQuery, + buildUserErasureDeletes, + parseUserSystemLogExportRows, + parseUserApiMetricExportRows, + parseUserErrorLogExportRows, + buildUserErrorIdsQuery, + collectChUserSystemLogs, + collectChUserApiMetrics, + collectChUserErrorLogs, + collectChUserErrorIds, + deleteChUserAnalytics, +} from '../analytics-gdpr'; + +const flat = (sql: string): string => sql.replace(/\s+/g, ' ').trim(); + +describe('GDPR export query builders — subject rows by user_id (#890 Phase 3 leaf 4)', () => { + it('given a userId, should bind it as a named param and never interpolate it', () => { + const malicious = "u'; DROP TABLE system_logs; --"; + for (const built of [ + buildUserSystemLogsExportQuery(malicious), + buildUserApiMetricsExportQuery(malicious), + buildUserErrorLogsExportQuery(malicious), + ]) { + expect(built.query).not.toContain(malicious); + expect(built.query).toContain('{userId: String}'); + expect(built.query_params).toEqual({ userId: malicious }); + } + }); + + it('given system_logs, should select exactly the Art 15 export columns (no ip/user_agent/stack telemetry)', () => { + const sql = flat(buildUserSystemLogsExportQuery('u1').query); + expect(sql).toContain('FROM system_logs'); + expect(sql).toContain('WHERE user_id = {userId: String}'); + for (const col of ['id', 'timestamp', 'level', 'message', 'category', 'endpoint', 'method', 'duration']) { + expect(sql).toContain(col); + } + for (const excluded of ['ip', 'user_agent', 'error_stack', 'session_id', 'request_id', 'metadata']) { + expect(sql).not.toContain(excluded); + } + }); + + it('given api_metrics, should select exactly the Art 15 export columns', () => { + const sql = flat(buildUserApiMetricsExportQuery('u1').query); + expect(sql).toContain('FROM api_metrics'); + for (const col of ['status_code', 'request_size', 'response_size', 'duration', 'endpoint', 'method']) { + expect(sql).toContain(col); + } + for (const excluded of ['ip', 'user_agent', 'session_id', 'request_id', 'cache_hit', 'cache_key', 'error']) { + expect(sql).not.toContain(excluded); + } + }); + + it('given error_logs, should select the Art 15 export columns (resolution state lives in PG, not CH)', () => { + const sql = flat(buildUserErrorLogsExportQuery('u1').query); + expect(sql).toContain('FROM error_logs'); + for (const col of ['name', 'message', 'file', 'line', '`column`']) { + expect(sql).toContain(col); + } + for (const excluded of ['resolved', 'stack', 'ip', 'user_agent', 'metadata']) { + expect(sql).not.toContain(excluded); + } + }); +}); + +describe('GDPR export row parsers — JSONEachRow → PG-parity export shapes', () => { + it('given system_logs rows, should convert timestamps to Date and category empty-string back to null', () => { + const rows = parseUserSystemLogExportRows([ + { + id: 'sl1', + timestamp: '2026-07-09 10:00:00.000', + level: 'info', + message: 'hello', + category: '', + endpoint: '/api/x', + method: 'GET', + duration: 12, + }, + { + id: 'sl2', + timestamp: '2026-07-09 11:00:00.000', + level: 'warn', + message: 'hi', + category: 'auth', + endpoint: null, + method: null, + duration: null, + }, + ]); + expect(rows[0].timestamp).toEqual(new Date('2026-07-09T10:00:00.000Z')); + expect(rows[0].category).toBeNull(); + expect(rows[1].category).toBe('auth'); + expect(rows[1].duration).toBeNull(); + }); + + it('given api_metrics rows, should map snake_case columns to the camelCase export shape', () => { + const rows = parseUserApiMetricExportRows([ + { + id: 'm1', + timestamp: '2026-07-09 10:00:00.000', + endpoint: '/api/x', + method: 'POST', + status_code: 201, + duration: 45, + request_size: 100, + response_size: null, + }, + ]); + expect(rows[0]).toEqual({ + id: 'm1', + timestamp: new Date('2026-07-09T10:00:00.000Z'), + endpoint: '/api/x', + method: 'POST', + statusCode: 201, + duration: 45, + requestSize: 100, + responseSize: null, + }); + }); + + it('given error_logs rows, should map columns without any resolution state', () => { + const rows = parseUserErrorLogExportRows([ + { + id: 'e1', + timestamp: '2026-07-09 10:00:00.000', + name: 'TypeError', + message: 'boom', + endpoint: null, + method: null, + file: 'a.ts', + line: 3, + column: 7, + }, + ]); + expect(rows[0]).toEqual({ + id: 'e1', + timestamp: new Date('2026-07-09T10:00:00.000Z'), + name: 'TypeError', + message: 'boom', + endpoint: null, + method: null, + file: 'a.ts', + line: 3, + column: 7, + }); + expect('resolved' in rows[0]).toBe(false); + }); +}); + +describe('buildUserErasureDeletes — Art 17 erasure mutations', () => { + it('given a userId, should build one lightweight DELETE per analytics table with a bound param', () => { + const deletes = buildUserErasureDeletes('victim-1'); + expect(deletes).toHaveLength(4); + const tables = deletes.map((d) => /DELETE FROM (\w+)/.exec(flat(d.query))?.[1]).sort(); + expect(tables).toEqual(['api_metrics', 'error_logs', 'system_logs', 'user_activities']); + for (const d of deletes) { + expect(flat(d.query)).toContain('WHERE user_id = {userId: String}'); + expect(d.query).not.toContain('victim-1'); + expect(d.query_params).toEqual({ userId: 'victim-1' }); + } + }); +}); + +describe('CH shells — export reads and erasure deletes', () => { + const mockClient = (rows: unknown[] = []) => ({ + query: vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue(rows) }), + command: vi.fn().mockResolvedValue(undefined), + }); + + it('given a client, collectCh* should query JSONEachRow and return parsed shapes', async () => { + const client = mockClient([ + { + id: 'sl1', + timestamp: '2026-07-09 10:00:00.000', + level: 'info', + message: 'hello', + category: '', + endpoint: null, + method: null, + duration: null, + }, + ]); + const rows = await collectChUserSystemLogs(client as never, 'u1'); + expect(client.query).toHaveBeenCalledWith( + expect.objectContaining({ format: 'JSONEachRow', query_params: { userId: 'u1' } }), + ); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe('sl1'); + }); + + it('given a read failure, collectCh* should PROPAGATE (an incomplete Art 15 export must fail, not silently omit)', async () => { + const client = { + query: vi.fn().mockRejectedValue(new Error('CH unreachable')), + command: vi.fn(), + }; + await expect(collectChUserApiMetrics(client as never, 'u1')).rejects.toThrow('CH unreachable'); + await expect(collectChUserErrorLogs(client as never, 'u1')).rejects.toThrow('CH unreachable'); + }); + + it('given a client, deleteChUserAnalytics should run all 4 erasure deletes through command()', async () => { + const client = mockClient(); + await deleteChUserAnalytics(client as never, 'victim-1'); + expect(client.command).toHaveBeenCalledTimes(4); + for (const call of client.command.mock.calls) { + const arg = call[0] as { query: string; query_params: Record }; + expect(flat(arg.query)).toMatch(/^DELETE FROM (api_metrics|system_logs|user_activities|error_logs) WHERE user_id = \{userId: String\}$/); + expect(arg.query_params).toEqual({ userId: 'victim-1' }); + } + }); + + it('given a delete failure, deleteChUserAnalytics should PROPAGATE (erasure is fail-closed)', async () => { + const client = { + query: vi.fn(), + command: vi.fn().mockRejectedValue(new Error('mutation rejected')), + }; + await expect(deleteChUserAnalytics(client as never, 'victim-1')).rejects.toThrow('mutation rejected'); + }); +}); + +describe('subject error ids — join keys for error_resolutions cleanup (#890 Phase 3 FIX)', () => { + // Erasure destroys the CH error rows, so their ids must be collected FIRST + // to delete the matching error_resolutions PG rows — afterwards the join + // key is gone and the resolutions become unreachable orphans. + it('given a userId, buildUserErrorIdsQuery should select only id with a bound param', () => { + const built = buildUserErrorIdsQuery("u'; DROP TABLE error_logs; --"); + const sql = flat(built.query); + expect(sql).toMatch(/^SELECT id FROM error_logs WHERE user_id = \{userId: String\}$/); + expect(built.query).not.toContain('DROP TABLE error_logs; --'); + expect(built.query_params).toEqual({ userId: "u'; DROP TABLE error_logs; --" }); + }); + + it('given a client, collectChUserErrorIds should return the plain id list', async () => { + const client = { + query: vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue([{ id: 'e1' }, { id: 'e2' }]), + }), + command: vi.fn(), + }; + const ids = await collectChUserErrorIds(client as never, 'u1'); + expect(ids).toEqual(['e1', 'e2']); + expect(client.query).toHaveBeenCalledWith( + expect.objectContaining({ format: 'JSONEachRow', query_params: { userId: 'u1' } }), + ); + }); + + it('given a read failure, collectChUserErrorIds should PROPAGATE (fail-closed like the rest of the GDPR path)', async () => { + const client = { + query: vi.fn().mockRejectedValue(new Error('CH unreachable')), + command: vi.fn(), + }; + await expect(collectChUserErrorIds(client as never, 'u1')).rejects.toThrow('CH unreachable'); + }); +}); diff --git a/packages/lib/src/observability/__tests__/analytics-inserts.test.ts b/packages/lib/src/observability/__tests__/analytics-inserts.test.ts new file mode 100644 index 0000000000..2057d3ce25 --- /dev/null +++ b/packages/lib/src/observability/__tests__/analytics-inserts.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + createAnalyticsInserters, + type AnalyticsInsertClient, +} from '../analytics-inserts'; +import { ClickHouseMisconfiguredError } from '../clickhouse-env'; + +const assignedDeps = { + createId: () => 'fixed-id', + now: () => new Date('2026-07-10T08:30:15.123Z'), +}; + +const makeClient = (): { client: AnalyticsInsertClient; insert: ReturnType } => { + const insert = vi.fn().mockResolvedValue(undefined); + return { client: { insert }, insert }; +}; + +describe('createAnalyticsInserters — pure (row) → void adapters over the buffer (#890 Phase 3)', () => { + it('given an api metric, insertApiMetric should buffer a mapped CH row and flush it as JSONEachRow', async () => { + const { client, insert } = makeClient(); + const inserters = createAnalyticsInserters({ getClient: () => client, ...assignedDeps }); + + inserters.insertApiMetric({ endpoint: '/api/x', method: 'GET', statusCode: 200, duration: 42 }); + await inserters.flush(); + + expect(insert).toHaveBeenCalledTimes(1); + const params = insert.mock.calls[0][0] as { + table: string; + values: Array>; + format: string; + }; + expect(params.table).toBe('api_metrics'); + expect(params.format).toBe('JSONEachRow'); + expect(params.values[0]).toMatchObject({ + id: 'fixed-id', + timestamp: '2026-07-10 08:30:15.123', + endpoint: '/api/x', + status_code: 200, + }); + }); + + it('given each adapter, should route to its own table', async () => { + const { client, insert } = makeClient(); + const inserters = createAnalyticsInserters({ getClient: () => client, ...assignedDeps }); + + inserters.insertApiMetric({ endpoint: '/e', method: 'GET', statusCode: 200, duration: 1 }); + inserters.insertSystemLog({ level: 'info', message: 'm' }); + inserters.insertUserActivity({ userId: 'u-1', action: 'a' }); + inserters.insertError({ name: 'E', message: 'm' }); + await inserters.flush(); + + const tables = insert.mock.calls.map((c) => (c[0] as { table: string }).table).sort(); + expect(tables).toEqual(['api_metrics', 'error_logs', 'system_logs', 'user_activities']); + }); + + it('given a caller-provided id and timestamp, should use them instead of generating', async () => { + const { client, insert } = makeClient(); + const inserters = createAnalyticsInserters({ getClient: () => client, ...assignedDeps }); + + inserters.insertApiMetric({ + id: 'caller-id', + timestamp: new Date('2025-01-01T00:00:00.000Z'), + endpoint: '/e', + method: 'GET', + statusCode: 200, + duration: 1, + }); + await inserters.flush(); + + const params = insert.mock.calls[0][0] as { values: Array> }; + expect(params.values[0].id).toBe('caller-id'); + expect(params.values[0].timestamp).toBe('2025-01-01 00:00:00.000'); + }); + + it('given rows below maxRows, should batch through the buffer (single flush, one insert call)', async () => { + const { client, insert } = makeClient(); + const inserters = createAnalyticsInserters({ getClient: () => client, ...assignedDeps }); + + inserters.insertSystemLog({ level: 'info', message: 'one' }); + inserters.insertSystemLog({ level: 'info', message: 'two' }); + inserters.insertSystemLog({ level: 'info', message: 'three' }); + await inserters.flush(); + + expect(insert).toHaveBeenCalledTimes(1); + const params = insert.mock.calls[0][0] as { values: unknown[] }; + expect(params.values).toHaveLength(3); + }); + + describe('never-throw contract', () => { + it('given getClient returns null (tier off), adapters should be silent no-ops', async () => { + const inserters = createAnalyticsInserters({ getClient: () => null, ...assignedDeps }); + + expect(() => inserters.insertApiMetric({ endpoint: '/e', method: 'GET', statusCode: 200, duration: 1 })).not.toThrow(); + expect(() => inserters.insertSystemLog({ level: 'info', message: 'm' })).not.toThrow(); + expect(() => inserters.insertUserActivity({ userId: 'u', action: 'a' })).not.toThrow(); + expect(() => inserters.insertError({ name: 'E', message: 'm' })).not.toThrow(); + await expect(inserters.flush()).resolves.toBeUndefined(); + }); + + it('given getClient throws (misconfigured), adapters should absorb and log payload-free', () => { + const logError = vi.fn(); + const inserters = createAnalyticsInserters({ + getClient: () => { + throw new Error('ClickHouse client init failed: CLICKHOUSE_USER is not set'); + }, + logError, + ...assignedDeps, + }); + + expect(() => + inserters.insertSystemLog({ level: 'error', message: 'SECRET-PAYLOAD', userId: 'SECRET-USER' }), + ).not.toThrow(); + expect(logError).toHaveBeenCalledTimes(1); + const logged = logError.mock.calls[0][0] as string; + expect(logged).toContain('system_logs'); + expect(logged).not.toContain('SECRET-PAYLOAD'); + expect(logged).not.toContain('SECRET-USER'); + }); + + it('given a client whose insert rejects, flush should absorb the failure and never log row payloads', async () => { + const logError = vi.fn(); + const insert = vi.fn().mockRejectedValue(new Error('network down')); + const inserters = createAnalyticsInserters({ + getClient: () => ({ insert }), + logError, + ...assignedDeps, + }); + + inserters.insertError({ name: 'E', message: 'SECRET-ERROR-DETAIL' }); + await expect(inserters.flush()).resolves.toBeUndefined(); + + expect(logError).toHaveBeenCalled(); + const allLogged = logError.mock.calls.flat().join(' '); + expect(allLogged).toContain('error_logs'); + expect(allLogged).not.toContain('SECRET-ERROR-DETAIL'); + }); + + it('given getClient throws ClickHouseMisconfiguredError, the log message should name the misconfiguration — NOT read as a transient drop (#890 Phase 3 FIX)', () => { + const logError = vi.fn(); + const inserters = createAnalyticsInserters({ + getClient: () => { + throw new ClickHouseMisconfiguredError( + "CLICKHOUSE_ENABLED='true' but CLICKHOUSE_PASSWORD is not set.", + ); + }, + logError, + ...assignedDeps, + }); + + inserters.insertSystemLog({ level: 'error', message: 'SECRET-PAYLOAD' }); + + expect(logError).toHaveBeenCalledTimes(1); + const logged = logError.mock.calls[0][0] as string; + expect(logged).toMatch(/MISCONFIGURED/); + expect(logged).toContain('system_logs'); + expect(logged).not.toContain('SECRET-PAYLOAD'); + }); + + it('given unserializable metadata (circular), the adapter should drop the row without throwing', () => { + const logError = vi.fn(); + const { client, insert } = makeClient(); + const inserters = createAnalyticsInserters({ getClient: () => client, logError, ...assignedDeps }); + + const circular: Record = {}; + circular.self = circular; + + expect(() => + inserters.insertUserActivity({ userId: 'u-1', action: 'a', metadata: circular }), + ).not.toThrow(); + expect(insert).not.toHaveBeenCalled(); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError.mock.calls[0][0]).toContain('user_activities'); + }); + }); + + it('given drain, should flush pending rows and await in-flight inserts', async () => { + const { client, insert } = makeClient(); + const inserters = createAnalyticsInserters({ getClient: () => client, ...assignedDeps }); + + inserters.insertUserActivity({ userId: 'u-1', action: 'a' }); + await inserters.drain(); + + expect(insert).toHaveBeenCalledTimes(1); + }); + + it('given the factory, should not touch the client until the first adapter call (lazy)', () => { + const getClient = vi.fn().mockReturnValue(null); + createAnalyticsInserters({ getClient, ...assignedDeps }); + expect(getClient).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/lib/src/observability/__tests__/analytics-read-core.test.ts b/packages/lib/src/observability/__tests__/analytics-read-core.test.ts new file mode 100644 index 0000000000..4a3ee454f4 --- /dev/null +++ b/packages/lib/src/observability/__tests__/analytics-read-core.test.ts @@ -0,0 +1,393 @@ +/** + * Pure core of the ClickHouse analytics READ path (#890 Phase 3 leaf 3). + * + * These tests pin the logic that makes the CH reader output-compatible with + * the old PG monitoring queries: UInt64-as-string coercion (JSONEachRow + * quotes 64-bit ints), UTC DateTime parsing back to Date objects (matching + * the node-postgres runtime shape), the system_logs.category ''→null + * un-mapping (leaf 2 mapped PG NULL to '' because category is an ORDER BY + * key), and query builders that bind every dynamic value as a named + * parameter (never string-interpolated). + */ +import { describe, it, expect } from 'vitest'; +import { + chCount, + chNullableNumber, + chDateTime, + emptyToNull, + parseChMetadata, + buildLogsByLevelQuery, + buildRecentErrorsQuery, + buildVolumeOverTimeQuery, + buildTopEndpointsQuery, + buildRequestErrorCountsQuery, + buildErrorTrendsQuery, + buildErrorPatternsQuery, + buildFailedLoginsQuery, + buildActivityHeatmapQuery, + buildMostActiveUsersQuery, + buildFeatureUsageQuery, + buildActiveUserCountQuery, + buildResponseTimesQuery, + buildSlowQueriesQuery, + buildEndpointPerformanceQuery, + parseLogsByLevelRows, + parseRecentErrorRows, + parseVolumeOverTimeRows, + parseTopEndpointRows, + parseRequestErrorCounts, + parseErrorTrendRows, + parseErrorPatternRows, + parseFailedLoginRows, + parseActivityHeatmapRows, + parseMostActiveUserRows, + parseFeatureUsageRows, + parseActiveUserCount, + parseResponseTimeRows, + parseSlowQueryRows, + parseEndpointPerformanceRows, +} from '../analytics-read-core'; + +const WINDOW = { + startDate: new Date('2021-03-05T10:00:00.000Z'), + endDate: new Date('2021-03-05T11:00:00.000Z'), +}; + +describe('chCount', () => { + it('given a UInt64 serialized as a string, should coerce to a number', () => { + expect(chCount('42')).toBe(42); + }); + + it('given an already-numeric value, should pass it through', () => { + expect(chCount(7)).toBe(7); + }); + + it('given null/undefined, should return 0', () => { + expect(chCount(null)).toBe(0); + expect(chCount(undefined)).toBe(0); + }); +}); + +describe('chNullableNumber', () => { + it('given a float, should return it', () => { + expect(chNullableNumber(12.5)).toBe(12.5); + }); + + it('given null (CH avg over empty set serializes nan as null), should return null', () => { + expect(chNullableNumber(null)).toBeNull(); + }); + + it('given a numeric string, should coerce', () => { + expect(chNullableNumber('99')).toBe(99); + }); +}); + +describe('chDateTime', () => { + it('given a DateTime64(3, UTC) string, should parse as UTC', () => { + expect(chDateTime('2021-03-05 10:30:00.123').toISOString()).toBe( + '2021-03-05T10:30:00.123Z', + ); + }); + + it('given a second-precision DateTime string (toStartOfHour output), should parse as UTC', () => { + expect(chDateTime('2021-03-05 10:00:00').toISOString()).toBe( + '2021-03-05T10:00:00.000Z', + ); + }); +}); + +describe('emptyToNull', () => { + it("given '' (the CH stand-in for PG NULL category), should return null", () => { + expect(emptyToNull('')).toBeNull(); + }); + + it('given a real category, should pass it through', () => { + expect(emptyToNull('auth')).toBe('auth'); + }); + + it('given null (Nullable CH column), should return null', () => { + expect(emptyToNull(null)).toBeNull(); + }); +}); + +describe('parseChMetadata', () => { + it('given a JSON-object string, should parse to the object', () => { + expect(parseChMetadata('{"a":1}')).toEqual({ a: 1 }); + }); + + it('given null, should return null', () => { + expect(parseChMetadata(null)).toBeNull(); + }); + + it('given malformed JSON, should return null rather than throw', () => { + expect(parseChMetadata('{oops')).toBeNull(); + }); + + it('given a JSON scalar (not an object), should return null to match the jsonb Record shape', () => { + expect(parseChMetadata('42')).toBeNull(); + }); +}); + +describe('query builders', () => { + it('given a full window, should bind start and end as named params (never interpolate)', () => { + const q = buildLogsByLevelQuery(WINDOW); + expect(q.query).toContain('FROM system_logs'); + expect(q.query).toContain('{tw_start: DateTime64(3)}'); + expect(q.query).toContain('{tw_end: DateTime64(3)}'); + expect(q.query).not.toContain('2021-03-05'); + expect(q.query_params.tw_start).toBe('2021-03-05 10:00:00.000'); + expect(q.query_params.tw_end).toBe('2021-03-05 11:00:00.000'); + }); + + it('given no window, should emit no window conditions', () => { + const q = buildLogsByLevelQuery({}); + expect(q.query).not.toContain('tw_start'); + expect(q.query).not.toContain('tw_end'); + expect(q.query_params).toEqual({}); + }); + + it('given recent-errors, should order desc by timestamp and bind the limit', () => { + const q = buildRecentErrorsQuery(WINDOW, 20); + expect(q.query).toContain('FROM error_logs'); + expect(q.query).toContain('ORDER BY timestamp DESC'); + expect(q.query).toContain('{limit: UInt32}'); + expect(q.query_params.limit).toBe(20); + }); + + it('given volume-over-time, should bucket by hour and cap at 168 buckets', () => { + const q = buildVolumeOverTimeQuery(WINDOW); + expect(q.query).toContain('FROM api_metrics'); + expect(q.query).toContain('toStartOfHour(timestamp)'); + expect(q.query_params.limit).toBe(168); + }); + + it('given top-endpoints, should group by endpoint, busiest first, top 10', () => { + const q = buildTopEndpointsQuery(WINDOW); + expect(q.query).toContain('GROUP BY endpoint'); + expect(q.query_params.limit).toBe(10); + }); + + it('given request/error counts, should count errors as status_code >= 400 in one pass', () => { + const q = buildRequestErrorCountsQuery(WINDOW); + expect(q.query).toContain('countIf(status_code >= 400)'); + }); + + it('given error trends, should filter level=error and group hour+category', () => { + const q = buildErrorTrendsQuery(WINDOW); + expect(q.query).toContain("level = 'error'"); + expect(q.query).toContain('FROM system_logs'); + }); + + it('given error patterns, should group by name+endpoint from error_logs', () => { + const q = buildErrorPatternsQuery(WINDOW); + expect(q.query).toContain('FROM error_logs'); + expect(q.query).toContain('GROUP BY name, endpoint'); + }); + + it('given failed logins, should filter category=auth at warn/error', () => { + const q = buildFailedLoginsQuery(WINDOW); + expect(q.query).toContain("category = 'auth'"); + expect(q.query).toContain("level IN ('warn', 'error')"); + }); + + it('given the heatmap, should convert CH day-of-week (1=Mon..7=Sun) to PG DOW (0=Sun..6=Sat) in SQL', () => { + const q = buildActivityHeatmapQuery(WINDOW); + expect(q.query).toContain('toDayOfWeek(timestamp) % 7'); + expect(q.query).toContain('toHour(timestamp)'); + expect(q.query).toContain('FROM user_activities'); + }); + + it('given most-active-users, should group by user_id with a bound limit', () => { + const q = buildMostActiveUsersQuery(WINDOW, 100); + expect(q.query).toContain('GROUP BY user_id'); + expect(q.query_params.limit).toBe(100); + }); + + it('given feature usage, should group by action, top 15', () => { + const q = buildFeatureUsageQuery(WINDOW); + expect(q.query).toContain('GROUP BY action'); + expect(q.query_params.limit).toBe(15); + }); + + it('given active-user count, should count distinct user_id since the bound instant', () => { + const q = buildActiveUserCountQuery(new Date('2021-03-05T10:45:00.000Z')); + expect(q.query).toContain('uniqExact(user_id)'); + expect(q.query_params.tw_start).toBe('2021-03-05 10:45:00.000'); + }); + + it('given response times, should aggregate avg/max/min per hour, 48 buckets', () => { + const q = buildResponseTimesQuery(WINDOW); + expect(q.query).toContain('max(duration)'); + expect(q.query).toContain('min(duration)'); + expect(q.query_params.limit).toBe(48); + }); + + it('given slow queries, should bind the duration threshold and order slowest first', () => { + const q = buildSlowQueriesQuery(WINDOW); + expect(q.query).toContain('duration >= {slow_ms: Int32}'); + expect(q.query).toContain('ORDER BY duration DESC'); + expect(q.query_params.slow_ms).toBe(5000); + }); + + it('given endpoint performance, should group by endpoint with no row cap (parity with the PG query)', () => { + const q = buildEndpointPerformanceQuery(WINDOW); + expect(q.query).toContain('GROUP BY endpoint'); + expect(q.query).not.toContain('LIMIT'); + }); +}); + +describe('row parsers', () => { + it('given logs-by-level rows, should coerce string counts', () => { + expect(parseLogsByLevelRows([{ level: 'error', count: '3' }])).toEqual([ + { level: 'error', count: 3 }, + ]); + }); + + it('given a recent-error row, should return the PG reader shape (errorName=name, errorMessage=stack)', () => { + const rows = parseRecentErrorRows([ + { + id: 'e1', + timestamp: '2021-03-05 10:30:00.123', + message: 'boom', + error_name: 'TypeError', + stack: null, + endpoint: '/api/x', + user_id: null, + }, + ]); + expect(rows).toEqual([ + { + id: 'e1', + timestamp: new Date('2021-03-05T10:30:00.123Z'), + message: 'boom', + errorName: 'TypeError', + errorMessage: null, + endpoint: '/api/x', + userId: null, + }, + ]); + }); + + it('given volume rows, should produce Date buckets and numeric aggregates', () => { + const rows = parseVolumeOverTimeRows([ + { hour: '2021-03-05 10:00:00', count: '2', avg_response_time: 15.5 }, + ]); + expect(rows).toEqual([ + { hour: new Date('2021-03-05T10:00:00.000Z'), count: 2, avg_response_time: 15.5 }, + ]); + }); + + it('given top-endpoint rows, should map avgResponseTime', () => { + expect( + parseTopEndpointRows([{ endpoint: '/a', count: '5', avg_response_time: 10 }]), + ).toEqual([{ endpoint: '/a', count: 5, avgResponseTime: 10 }]); + }); + + it('given a request/error count row, should return numeric totals', () => { + expect(parseRequestErrorCounts([{ total: '10', errors: '2' }])).toEqual({ + total: 10, + errors: 2, + }); + }); + + it('given no request/error count rows, should return zeros', () => { + expect(parseRequestErrorCounts([])).toEqual({ total: 0, errors: 0 }); + }); + + it("given an error-trend row with category '', should un-map to null (PG NULL parity)", () => { + const rows = parseErrorTrendRows([ + { hour: '2021-03-05 10:00:00', category: '', count: '1' }, + ]); + expect(rows).toEqual([ + { hour: new Date('2021-03-05T10:00:00.000Z'), category: null, count: 1 }, + ]); + }); + + it('given error-pattern rows, should keep nullable endpoint', () => { + expect(parseErrorPatternRows([{ name: 'E', endpoint: null, count: '4' }])).toEqual([ + { name: 'E', endpoint: null, count: 4 }, + ]); + }); + + it('given failed-login rows, should parse metadata JSON back to an object', () => { + const rows = parseFailedLoginRows([ + { timestamp: '2021-03-05 10:30:00.000', ip: '1.2.3.4', metadata: '{"email":"[masked]"}' }, + ]); + expect(rows).toEqual([ + { + timestamp: new Date('2021-03-05T10:30:00.000Z'), + ip: '1.2.3.4', + metadata: { email: '[masked]' }, + }, + ]); + }); + + it('given heatmap rows, should coerce all three fields to numbers', () => { + expect( + parseActivityHeatmapRows([{ day_of_week: 5, hour_of_day: 10, activity_count: '3' }]), + ).toEqual([{ day_of_week: 5, hour_of_day: 10, activity_count: 3 }]); + }); + + it('given most-active-user rows, should map user_id/action_count', () => { + expect(parseMostActiveUserRows([{ user_id: 'u1', action_count: '9' }])).toEqual([ + { userId: 'u1', actionCount: 9 }, + ]); + }); + + it('given feature-usage rows, should coerce counts', () => { + expect(parseFeatureUsageRows([{ action: 'create', count: '2' }])).toEqual([ + { action: 'create', count: 2 }, + ]); + }); + + it('given an active-user count row, should return the number (0 when empty)', () => { + expect(parseActiveUserCount([{ count: '4' }])).toBe(4); + expect(parseActiveUserCount([])).toBe(0); + }); + + it('given response-time rows, should map the three aggregates', () => { + expect( + parseResponseTimeRows([ + { + hour: '2021-03-05 10:00:00', + avg_response_time: 10.5, + max_response_time: 20, + min_response_time: 1, + }, + ]), + ).toEqual([ + { + hour: new Date('2021-03-05T10:00:00.000Z'), + avg_response_time: 10.5, + max_response_time: 20, + min_response_time: 1, + }, + ]); + }); + + it('given slow-query rows, should return the PG reader shape (responseTime=duration)', () => { + expect( + parseSlowQueryRows([ + { + endpoint: '/slow', + response_time: 6000, + timestamp: '2021-03-05 10:30:00.000', + user_id: 'u1', + }, + ]), + ).toEqual([ + { + endpoint: '/slow', + responseTime: 6000, + timestamp: new Date('2021-03-05T10:30:00.000Z'), + userId: 'u1', + }, + ]); + }); + + it('given endpoint-performance rows, should map metric/avgValue/count', () => { + expect( + parseEndpointPerformanceRows([{ metric: '/a', avg_value: 3.5, count: '7' }]), + ).toEqual([{ metric: '/a', avgValue: 3.5, count: 7 }]); + }); +}); diff --git a/packages/lib/src/observability/__tests__/analytics-rows.test.ts b/packages/lib/src/observability/__tests__/analytics-rows.test.ts new file mode 100644 index 0000000000..2744c64b90 --- /dev/null +++ b/packages/lib/src/observability/__tests__/analytics-rows.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect } from 'vitest'; +import { + toClickHouseDateTime64, + mapApiMetricToRow, + mapSystemLogToRow, + mapUserActivityToRow, + mapErrorLogToRow, +} from '../analytics-rows'; + +const assigned = { + id: 'row-id-1', + timestamp: new Date('2026-07-10T08:30:15.123Z'), +}; +const CH_TS = '2026-07-10 08:30:15.123'; + +describe('toClickHouseDateTime64', () => { + it('given a Date, should format as UTC YYYY-MM-DD HH:mm:ss.SSS for DateTime64(3) basic parsing', () => { + expect(toClickHouseDateTime64(new Date('2026-07-10T08:30:15.123Z'))).toBe(CH_TS); + }); + + it('given a midnight boundary, should keep zero-padded fields', () => { + expect(toClickHouseDateTime64(new Date('2026-01-02T00:00:00.000Z'))).toBe('2026-01-02 00:00:00.000'); + }); +}); + +describe('mapApiMetricToRow — PG write shape → CH row', () => { + it('given required fields only, should map them and null every optional column', () => { + const row = mapApiMetricToRow( + { endpoint: '/api/x', method: 'GET', statusCode: 200, duration: 42 }, + assigned, + ); + expect(row).toEqual({ + id: 'row-id-1', + timestamp: CH_TS, + endpoint: '/api/x', + method: 'GET', + status_code: 200, + duration: 42, + request_size: null, + response_size: null, + user_id: null, + session_id: null, + ip: null, + user_agent: null, + error: null, + request_id: null, + cache_hit: null, + cache_key: null, + }); + }); + + it('given all optional fields, should map camelCase → snake_case faithfully', () => { + const row = mapApiMetricToRow( + { + endpoint: '/api/y', + method: 'POST', + statusCode: 201, + duration: 5, + requestSize: 512, + responseSize: 1024, + userId: 'u-1', + sessionId: 's-1', + ip: '10.0.0.1', + userAgent: 'UA/1.0', + error: 'boom', + requestId: 'r-1', + cacheHit: true, + cacheKey: 'k', + }, + assigned, + ); + expect(row.request_size).toBe(512); + expect(row.response_size).toBe(1024); + expect(row.user_id).toBe('u-1'); + expect(row.session_id).toBe('s-1'); + expect(row.cache_hit).toBe(true); + expect(row.cache_key).toBe('k'); + }); + + it('given cacheHit false, should keep false (not null)', () => { + const row = mapApiMetricToRow( + { endpoint: '/e', method: 'GET', statusCode: 200, duration: 1, cacheHit: false }, + assigned, + ); + expect(row.cache_hit).toBe(false); + }); +}); + +describe('mapSystemLogToRow — PG write shape → CH row', () => { + it('given required fields only, should null optionals and default category to empty string (ORDER BY key)', () => { + const row = mapSystemLogToRow({ level: 'info', message: 'hello' }, assigned); + expect(row).toEqual({ + id: 'row-id-1', + timestamp: CH_TS, + level: 'info', + message: 'hello', + category: '', + user_id: null, + session_id: null, + request_id: null, + drive_id: null, + page_id: null, + endpoint: null, + method: null, + ip: null, + user_agent: null, + error_name: null, + error_message: null, + error_stack: null, + duration: null, + memory_used: null, + memory_total: null, + metadata: null, + hostname: null, + pid: null, + version: null, + }); + }); + + it('given full context, should map every column', () => { + const row = mapSystemLogToRow( + { + level: 'error', + message: 'failed', + category: 'api', + userId: 'u-1', + sessionId: 's-1', + requestId: 'r-1', + driveId: 'd-1', + pageId: 'p-1', + endpoint: '/api/z', + method: 'PUT', + ip: '127.0.0.1', + userAgent: 'UA', + errorName: 'TypeError', + errorMessage: 'bad', + errorStack: 'stack', + duration: 9, + memoryUsed: 50, + memoryTotal: 100, + metadata: { a: 1 }, + hostname: 'h', + pid: 1234, + version: '1.0.0', + }, + assigned, + ); + expect(row.category).toBe('api'); + expect(row.user_id).toBe('u-1'); + expect(row.drive_id).toBe('d-1'); + expect(row.error_name).toBe('TypeError'); + expect(row.memory_used).toBe(50); + expect(row.memory_total).toBe(100); + expect(row.hostname).toBe('h'); + expect(row.pid).toBe(1234); + }); + + it('given jsonb-shaped metadata, should serialize to a JSON string (CH String column)', () => { + const row = mapSystemLogToRow( + { level: 'info', message: 'm', metadata: { key: 'val', n: 5 } }, + assigned, + ); + expect(row.metadata).toBe(JSON.stringify({ key: 'val', n: 5 })); + }); +}); + +describe('mapUserActivityToRow — PG write shape → CH row', () => { + it('given required fields only, should map user_id and action and null optionals', () => { + const row = mapUserActivityToRow({ userId: 'u-1', action: 'page_view' }, assigned); + expect(row).toEqual({ + id: 'row-id-1', + timestamp: CH_TS, + user_id: 'u-1', + action: 'page_view', + session_id: null, + resource: null, + resource_id: null, + drive_id: null, + page_id: null, + metadata: null, + ip: null, + user_agent: null, + }); + }); + + it('given full fields, should map camelCase → snake_case and serialize metadata', () => { + const row = mapUserActivityToRow( + { + userId: 'u-2', + action: 'delete', + resource: 'conversation', + resourceId: 'c-1', + driveId: 'd-1', + pageId: 'p-1', + sessionId: 's-1', + ip: '10.0.0.2', + userAgent: 'UA', + metadata: { reason: 'user_initiated' }, + }, + assigned, + ); + expect(row.resource).toBe('conversation'); + expect(row.resource_id).toBe('c-1'); + expect(row.metadata).toBe(JSON.stringify({ reason: 'user_initiated' })); + }); +}); + +describe('mapErrorLogToRow — PG write shape → CH row', () => { + it('given required fields only, should map name/message and null optionals', () => { + const row = mapErrorLogToRow({ name: 'Error', message: 'oops' }, assigned); + expect(row).toEqual({ + id: 'row-id-1', + timestamp: CH_TS, + name: 'Error', + message: 'oops', + stack: null, + user_id: null, + session_id: null, + request_id: null, + endpoint: null, + method: null, + file: null, + line: null, + column: null, + ip: null, + user_agent: null, + metadata: null, + }); + }); + + it('given location fields, should map file/line/column', () => { + const row = mapErrorLogToRow( + { name: 'E', message: 'm', file: 'server.ts', line: 42, column: 7 }, + assigned, + ); + expect(row.file).toBe('server.ts'); + expect(row.line).toBe(42); + expect(row.column).toBe(7); + }); +}); diff --git a/packages/lib/src/observability/__tests__/clickhouse-buffer.test.ts b/packages/lib/src/observability/__tests__/clickhouse-buffer.test.ts new file mode 100644 index 0000000000..73317714cf --- /dev/null +++ b/packages/lib/src/observability/__tests__/clickhouse-buffer.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createClickHouseBuffer, + DEFAULT_MAX_ROWS, + DEFAULT_FLUSH_INTERVAL_MS, +} from '../clickhouse-buffer'; + +interface TestRow { + id: number; + secret: string; +} + +const row = (id: number): TestRow => ({ id, secret: `pii-payload-${id}` }); + +describe('createClickHouseBuffer — buffer contract (#890 Phase 3 design ref)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('pure factory', () => { + it('given two buffers for different tables, should keep fully independent state (no module-level state)', async () => { + const insertA = vi.fn().mockResolvedValue(undefined); + const insertB = vi.fn().mockResolvedValue(undefined); + const a = createClickHouseBuffer('api_metrics', { insert: insertA }); + const b = createClickHouseBuffer('system_logs', { insert: insertB }); + + a.insert(row(1)); + await a.flush(); + + expect(insertA).toHaveBeenCalledTimes(1); + expect(insertB).not.toHaveBeenCalled(); + expect(b.pendingCount()).toBe(0); + }); + + it('given the factory is created, should not schedule timers or touch the client until the first insert', () => { + const insert = vi.fn().mockResolvedValue(undefined); + createClickHouseBuffer('api_metrics', { insert }); + + vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL_MS * 5); + + expect(insert).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + }); + + describe('accumulation below threshold', () => { + it('given fewer rows than the threshold and no timer tick, should accumulate without flushing', () => { + const insert = vi.fn().mockResolvedValue(undefined); + const buffer = createClickHouseBuffer('api_metrics', { insert }); + + for (let i = 0; i < DEFAULT_MAX_ROWS - 1; i++) buffer.insert(row(i)); + + expect(insert).not.toHaveBeenCalled(); + expect(buffer.pendingCount()).toBe(DEFAULT_MAX_ROWS - 1); + }); + }); + + describe('flush at row threshold', () => { + it('given the 500th row (default), should auto-flush the whole batch to the injected insert', () => { + const insert = vi.fn().mockResolvedValue(undefined); + const buffer = createClickHouseBuffer('api_metrics', { insert }); + + for (let i = 0; i < DEFAULT_MAX_ROWS; i++) buffer.insert(row(i)); + + expect(insert).toHaveBeenCalledTimes(1); + expect(insert).toHaveBeenCalledWith({ + table: 'api_metrics', + values: expect.arrayContaining([row(0), row(DEFAULT_MAX_ROWS - 1)]) as TestRow[], + }); + const call = insert.mock.calls[0][0] as { values: TestRow[] }; + expect(call.values).toHaveLength(DEFAULT_MAX_ROWS); + expect(buffer.pendingCount()).toBe(0); + }); + + it('given a custom maxRows, should flush at that threshold instead', () => { + const insert = vi.fn().mockResolvedValue(undefined); + const buffer = createClickHouseBuffer('api_metrics', { insert, maxRows: 3 }); + + buffer.insert(row(1)); + buffer.insert(row(2)); + expect(insert).not.toHaveBeenCalled(); + buffer.insert(row(3)); + + expect(insert).toHaveBeenCalledTimes(1); + }); + }); + + describe('flush on timer tick', () => { + it('given a pending row and 1000ms elapsed (default), should flush via the timer', () => { + const insert = vi.fn().mockResolvedValue(undefined); + const buffer = createClickHouseBuffer('api_metrics', { insert }); + + buffer.insert(row(1)); + expect(insert).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL_MS); + + expect(insert).toHaveBeenCalledTimes(1); + expect(insert).toHaveBeenCalledWith({ table: 'api_metrics', values: [row(1)] }); + expect(buffer.pendingCount()).toBe(0); + }); + + it('given a custom flushIntervalMs, should flush on that interval instead', () => { + const insert = vi.fn().mockResolvedValue(undefined); + const buffer = createClickHouseBuffer('api_metrics', { + insert, + flushIntervalMs: 250, + }); + + buffer.insert(row(1)); + vi.advanceTimersByTime(249); + expect(insert).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(insert).toHaveBeenCalledTimes(1); + }); + + it('given a threshold flush already happened, should not double-flush on the stale timer', () => { + const insert = vi.fn().mockResolvedValue(undefined); + const buffer = createClickHouseBuffer('api_metrics', { insert, maxRows: 2 }); + + buffer.insert(row(1)); + buffer.insert(row(2)); // threshold flush + expect(insert).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL_MS * 2); + expect(insert).toHaveBeenCalledTimes(1); // no empty second flush + }); + }); + + describe('never throw / never block the request path', () => { + it('given the client insert rejects, flush() should resolve without throwing and discard the batch', async () => { + const insert = vi.fn().mockRejectedValue(new Error('CH down')); + const buffer = createClickHouseBuffer('api_metrics', { + insert, + logError: vi.fn(), + }); + + buffer.insert(row(1)); + await expect(buffer.flush()).resolves.toBeUndefined(); + expect(buffer.pendingCount()).toBe(0); // discarded, not retried + }); + + it('given the client insert throws synchronously, insert()/flush() should still never throw', async () => { + const insert = vi.fn().mockImplementation(() => { + throw new Error('sync explosion'); + }); + const buffer = createClickHouseBuffer('api_metrics', { + insert, + maxRows: 1, + logError: vi.fn(), + }); + + expect(() => buffer.insert(row(1))).not.toThrow(); + buffer.insert(row(2)); + await expect(buffer.flush()).resolves.toBeUndefined(); + }); + + it('given a timer-tick flush failure, should not produce an unhandled rejection', async () => { + const insert = vi.fn().mockRejectedValue(new Error('CH down')); + const buffer = createClickHouseBuffer('api_metrics', { + insert, + logError: vi.fn(), + }); + + buffer.insert(row(1)); + vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL_MS); + await vi.runAllTimersAsync(); + + expect(insert).toHaveBeenCalledTimes(1); + }); + + it('given a flush in progress, a concurrent insert() should return synchronously and land in the next batch (double-buffer)', async () => { + let resolveFirst: (() => void) | undefined; + const insert = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValue(undefined); + const buffer = createClickHouseBuffer('api_metrics', { insert }); + + buffer.insert(row(1)); + const firstFlush = buffer.flush(); // in-flight, unresolved + + buffer.insert(row(2)); // must not block or be swallowed by the in-flight batch + expect(buffer.pendingCount()).toBe(1); + + resolveFirst?.(); + await firstFlush; + await buffer.flush(); + + expect(insert).toHaveBeenCalledTimes(2); + expect(insert).toHaveBeenNthCalledWith(1, { table: 'api_metrics', values: [row(1)] }); + expect(insert).toHaveBeenNthCalledWith(2, { table: 'api_metrics', values: [row(2)] }); + }); + }); + + describe('flush error logging — table name, NEVER row payloads (PII)', () => { + it('given a flush failure, should log the table name and error message', async () => { + const logError = vi.fn(); + const insert = vi.fn().mockRejectedValue(new Error('code 516: auth failed')); + const buffer = createClickHouseBuffer('user_activities', { insert, logError }); + + buffer.insert(row(1)); + await buffer.flush(); + + expect(logError).toHaveBeenCalledTimes(1); + const message = logError.mock.calls[0][0] as string; + expect(message).toContain('user_activities'); + expect(message).toContain('code 516: auth failed'); + }); + + it('given a flush failure, the log line must NOT contain any row payload', async () => { + const logError = vi.fn(); + const insert = vi.fn().mockRejectedValue(new Error('CH down')); + const buffer = createClickHouseBuffer('user_activities', { insert, logError }); + + buffer.insert(row(42)); + await buffer.flush(); + + const message = logError.mock.calls[0][0] as string; + expect(message).not.toContain('pii-payload-42'); + expect(message).not.toContain('"id"'); + expect(message).not.toContain('42'); + }); + + it('given a non-Error rejection, should still log a string message without payload', async () => { + const logError = vi.fn(); + const insert = vi.fn().mockRejectedValue('string failure'); + const buffer = createClickHouseBuffer('user_activities', { insert, logError }); + + buffer.insert(row(1)); + await buffer.flush(); + + const message = logError.mock.calls[0][0] as string; + expect(message).toContain('string failure'); + expect(message).not.toContain('pii-payload-1'); + }); + }); + + describe('drain()', () => { + it('given pending rows, drain() should resolve only after flushing them all', async () => { + const inserted: TestRow[][] = []; + const insert = vi.fn().mockImplementation((params: { values: TestRow[] }) => { + inserted.push(params.values); + return Promise.resolve(); + }); + const buffer = createClickHouseBuffer('api_metrics', { insert }); + + buffer.insert(row(1)); + buffer.insert(row(2)); + await buffer.drain(); + + expect(inserted.flat()).toEqual([row(1), row(2)]); + expect(buffer.pendingCount()).toBe(0); + }); + + it('given an in-flight flush plus new pending rows, drain() should await both batches', async () => { + let resolveFirst: (() => void) | undefined; + const insert = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValue(undefined); + const buffer = createClickHouseBuffer('api_metrics', { insert }); + + buffer.insert(row(1)); + void buffer.flush(); // in-flight + buffer.insert(row(2)); + + let drained = false; + const drainPromise = buffer.drain().then(() => { + drained = true; + }); + + await Promise.resolve(); + expect(drained).toBe(false); // first batch still in flight + + resolveFirst?.(); + await drainPromise; + + expect(insert).toHaveBeenCalledTimes(2); + }); + + it('given an empty buffer, drain() should resolve immediately and cancel any pending timer', async () => { + const insert = vi.fn().mockResolvedValue(undefined); + const buffer = createClickHouseBuffer('api_metrics', { insert }); + + buffer.insert(row(1)); + await buffer.flush(); + await buffer.drain(); + + expect(vi.getTimerCount()).toBe(0); + expect(insert).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/lib/src/observability/__tests__/clickhouse-client.test.ts b/packages/lib/src/observability/__tests__/clickhouse-client.test.ts new file mode 100644 index 0000000000..9da2387a80 --- /dev/null +++ b/packages/lib/src/observability/__tests__/clickhouse-client.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { ClickHouseClient } from '@clickhouse/client'; +import { + createClickHouseRegistry, + probeClickHouseStartup, + type ClickHouseRegistryDeps, +} from '../clickhouse-client'; +import { ClickHouseMisconfiguredError, type ClickHouseEnv } from '../clickhouse-env'; + +const fakeClient = {} as ClickHouseClient; + +const makeDeps = ( + env: ClickHouseEnv, + overrides: Partial = {}, +): ClickHouseRegistryDeps => ({ + getEnv: () => env, + createClient: vi.fn().mockReturnValue(fakeClient), + ...overrides, +}); + +const configuredEnv: ClickHouseEnv = { + CLICKHOUSE_ENABLED: 'true', + CLICKHOUSE_HOST: 'http://localhost:8123', + CLICKHOUSE_USER: 'user', + CLICKHOUSE_PASSWORD: 'password', + CLICKHOUSE_DATABASE: 'pagespace_analytics', +}; + +describe('createClickHouseRegistry — lazy client shell (#890 Phase 3)', () => { + describe('off (default) — zero behavior change', () => { + it('given CLICKHOUSE_ENABLED unset, getClient() should return null and never construct a client', () => { + const createClient = vi.fn(); + const registry = createClickHouseRegistry(makeDeps({}, { createClient })); + + expect(registry.getClient()).toBeNull(); + expect(createClient).not.toHaveBeenCalled(); + }); + + it('given the feature off, getClient() should not throw even with all connection vars missing', () => { + const registry = createClickHouseRegistry(makeDeps({ CLICKHOUSE_ENABLED: 'false' })); + expect(() => registry.getClient()).not.toThrow(); + }); + }); + + describe('on + configured', () => { + it('given a configured env, getClient() should build the client from the resolved config', () => { + const createClient = vi.fn().mockReturnValue(fakeClient); + const registry = createClickHouseRegistry(makeDeps(configuredEnv, { createClient })); + + expect(registry.getClient()).toBe(fakeClient); + expect(createClient).toHaveBeenCalledWith({ + url: 'http://localhost:8123', + username: 'user', + password: 'password', + database: 'pagespace_analytics', + }); + }); + + it('given repeated getClient() calls, should construct the client exactly once (per-process cache)', () => { + const createClient = vi.fn().mockReturnValue(fakeClient); + const registry = createClickHouseRegistry(makeDeps(configuredEnv, { createClient })); + + registry.getClient(); + registry.getClient(); + + expect(createClient).toHaveBeenCalledTimes(1); + }); + + it('given import/construction of the registry alone, should have no side effects (lazy init)', () => { + const createClient = vi.fn(); + createClickHouseRegistry(makeDeps(configuredEnv, { createClient })); + expect(createClient).not.toHaveBeenCalled(); + }); + }); + + describe('on + misconfigured — fail fast, never silently drop', () => { + it('given the flag on but connection config missing, getClient() should throw with the misconfiguration reason', () => { + const registry = createClickHouseRegistry(makeDeps({ CLICKHOUSE_ENABLED: 'true' })); + expect(() => registry.getClient()).toThrow(/CLICKHOUSE_HOST/); + }); + + it('given the flag on but password missing, getClient() should throw naming the missing var', () => { + const registry = createClickHouseRegistry( + makeDeps({ ...configuredEnv, CLICKHOUSE_PASSWORD: undefined }), + ); + expect(() => registry.getClient()).toThrow(/CLICKHOUSE_PASSWORD/); + }); + }); + + describe('getMode() — pure observation', () => { + it('given any env, getMode() should report the decision without constructing a client or throwing', () => { + const createClient = vi.fn(); + const registry = createClickHouseRegistry( + makeDeps({ CLICKHOUSE_ENABLED: 'true' }, { createClient }), + ); + + expect(registry.getMode().mode).toBe('misconfigured'); + expect(createClient).not.toHaveBeenCalled(); + }); + + it('given env read lazily, getMode() should re-read env each call (late-loaded dotenv wins)', () => { + let env: ClickHouseEnv = {}; + const registry = createClickHouseRegistry(makeDeps({}, { getEnv: () => env })); + + expect(registry.getMode().mode).toBe('disabled'); + env = configuredEnv; + expect(registry.getMode().mode).toBe('enabled'); + }); + }); +}); + +describe('getGdprClient() — reaches CH wherever subject data COULD live (#890 Phase 3 FIX)', () => { + it('given no CH env at all, should return null and never construct', () => { + const createClient = vi.fn(); + const registry = createClickHouseRegistry(makeDeps({}, { createClient })); + + expect(registry.getGdprClient()).toBeNull(); + expect(createClient).not.toHaveBeenCalled(); + }); + + it('given full config with the flag OFF (rollback window), should STILL return a client', () => { + const createClient = vi.fn().mockReturnValue(fakeClient); + const registry = createClickHouseRegistry( + makeDeps({ ...configuredEnv, CLICKHOUSE_ENABLED: undefined }, { createClient }), + ); + + expect(registry.getGdprClient()).toBe(fakeClient); + }); + + it('given partial config, should throw ClickHouseMisconfiguredError (fail-closed for GDPR)', () => { + const registry = createClickHouseRegistry( + makeDeps({ CLICKHOUSE_HOST: 'my-cluster.clickhouse.cloud' }), + ); + + expect(() => registry.getGdprClient()).toThrow(ClickHouseMisconfiguredError); + }); + + it('given the flag on and configured, getClient() and getGdprClient() should share one instance', () => { + const createClient = vi.fn().mockReturnValue(fakeClient); + const registry = createClickHouseRegistry(makeDeps(configuredEnv, { createClient })); + + expect(registry.getClient()).toBe(registry.getGdprClient()); + expect(createClient).toHaveBeenCalledTimes(1); + }); +}); + +describe('misconfigured throws are typed (#890 Phase 3 FIX — adapters must distinguish them from flush failures)', () => { + it('given flag on + config missing, getClient() should throw ClickHouseMisconfiguredError', () => { + const registry = createClickHouseRegistry(makeDeps({ CLICKHOUSE_ENABLED: 'true' })); + expect(() => registry.getClient()).toThrow(ClickHouseMisconfiguredError); + }); +}); + +describe('probeClickHouseStartup() — composition-root fail-fast (#890 Phase 3 FIX)', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('given a half-configured deploy (flag on, no creds), should THROW so the process crashes at startup', () => { + vi.stubEnv('CLICKHOUSE_ENABLED', 'true'); + vi.stubEnv('CLICKHOUSE_URL', ''); + vi.stubEnv('CLICKHOUSE_HOST', ''); + vi.stubEnv('CLICKHOUSE_USER', ''); + vi.stubEnv('CLICKHOUSE_PASSWORD', ''); + vi.stubEnv('CLICKHOUSE_DATABASE', ''); + + expect(() => probeClickHouseStartup()).toThrow(ClickHouseMisconfiguredError); + }); + + it('given the tier off, should return the disabled decision without throwing', () => { + vi.stubEnv('CLICKHOUSE_ENABLED', ''); + + expect(probeClickHouseStartup().mode).toBe('disabled'); + }); + + it('given the tier on and configured, should return the enabled decision (no client construction)', () => { + vi.stubEnv('CLICKHOUSE_ENABLED', 'true'); + vi.stubEnv('CLICKHOUSE_URL', 'http://localhost:8123'); + vi.stubEnv('CLICKHOUSE_USER', 'user'); + vi.stubEnv('CLICKHOUSE_PASSWORD', 'password'); + vi.stubEnv('CLICKHOUSE_DATABASE', 'pagespace_analytics'); + + expect(probeClickHouseStartup().mode).toBe('enabled'); + }); +}); diff --git a/packages/lib/src/observability/__tests__/clickhouse-ddl.test.ts b/packages/lib/src/observability/__tests__/clickhouse-ddl.test.ts new file mode 100644 index 0000000000..cb8472bd6c --- /dev/null +++ b/packages/lib/src/observability/__tests__/clickhouse-ddl.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + ANALYTICS_TABLE_DDL, + ensureAnalyticsTables, +} from '../clickhouse-ddl'; +import { ANALYTICS_TABLES } from '../analytics-rows'; + +const ddlFor = (table: string): string => { + const entry = ANALYTICS_TABLE_DDL.find((d) => d.table === table); + if (!entry) throw new Error(`no DDL for table ${table}`); + return entry.ddl; +}; + +// Collapse whitespace so clause assertions are formatting-independent. +const flat = (ddl: string): string => ddl.replace(/\s+/g, ' '); + +describe('ANALYTICS_TABLE_DDL — MergeTree schema per design ref lkjmhbmjekgbntk2z4rsjzw2 (#890 Phase 3)', () => { + it('given the DDL set, should define exactly the 4 analytics tables', () => { + expect(ANALYTICS_TABLE_DDL.map((d) => d.table).sort()).toEqual( + ['api_metrics', 'error_logs', 'system_logs', 'user_activities'], + ); + expect(Object.values(ANALYTICS_TABLES).sort()).toEqual( + ['api_metrics', 'error_logs', 'system_logs', 'user_activities'], + ); + }); + + it('given any table, should be idempotent (CREATE TABLE IF NOT EXISTS) and partitioned by toYYYYMM(timestamp)', () => { + for (const { table, ddl } of ANALYTICS_TABLE_DDL) { + expect(flat(ddl)).toContain(`CREATE TABLE IF NOT EXISTS ${table}`); + expect(flat(ddl)).toContain('ENGINE = MergeTree'); + expect(flat(ddl)).toContain('PARTITION BY toYYYYMM(timestamp)'); + } + }); + + describe('api_metrics', () => { + it('given the hot path, should ORDER BY (timestamp, endpoint, status_code)', () => { + expect(flat(ddlFor('api_metrics'))).toContain('ORDER BY (timestamp, endpoint, status_code)'); + }); + + it('given bounded string columns, should mark endpoint and method LowCardinality', () => { + const ddl = flat(ddlFor('api_metrics')); + expect(ddl).toContain('`endpoint` LowCardinality(String)'); + expect(ddl).toContain('`method` LowCardinality(String)'); + }); + + it('given 90d retention, should carry a 90 DAY TTL on timestamp', () => { + expect(flat(ddlFor('api_metrics'))).toContain('TTL toDateTime(timestamp) + INTERVAL 90 DAY'); + }); + }); + + describe('system_logs', () => { + it('given the hot path, should ORDER BY (timestamp, level, category)', () => { + expect(flat(ddlFor('system_logs'))).toContain('ORDER BY (timestamp, level, category)'); + }); + + it('given bounded string columns, should mark level and category LowCardinality', () => { + const ddl = flat(ddlFor('system_logs')); + expect(ddl).toContain('`level` LowCardinality(String)'); + // category is an ORDER BY key so it cannot be Nullable — PG NULL maps to ''. + expect(ddl).toContain("`category` LowCardinality(String) DEFAULT ''"); + }); + + it('given 30d retention, should carry a 30 DAY TTL on timestamp', () => { + expect(flat(ddlFor('system_logs'))).toContain('TTL toDateTime(timestamp) + INTERVAL 30 DAY'); + }); + }); + + describe('user_activities', () => { + it('given the per-user hot path, should ORDER BY (user_id, timestamp, action)', () => { + expect(flat(ddlFor('user_activities'))).toContain('ORDER BY (user_id, timestamp, action)'); + }); + + it('given bounded string columns, should mark action LowCardinality', () => { + expect(flat(ddlFor('user_activities'))).toContain('`action` LowCardinality(String)'); + }); + + it('given 180d retention, should carry a 180 DAY TTL on timestamp', () => { + expect(flat(ddlFor('user_activities'))).toContain('TTL toDateTime(timestamp) + INTERVAL 180 DAY'); + }); + }); + + describe('error_logs', () => { + it('given the hot path, should ORDER BY (timestamp, name)', () => { + expect(flat(ddlFor('error_logs'))).toContain('ORDER BY (timestamp, name)'); + }); + + it('given bounded error names, should mark name LowCardinality', () => { + expect(flat(ddlFor('error_logs'))).toContain('`name` LowCardinality(String)'); + }); + + it('given the no-retention rule, should carry NO TTL', () => { + expect(flat(ddlFor('error_logs'))).not.toContain('TTL '); + }); + + it('given CH rows are immutable, should NOT mirror the mutable PG resolution workflow columns', () => { + const ddl = flat(ddlFor('error_logs')); + // resolved/resolved_at/resolved_by/resolution live in a main-PG mini-table (leaf 3). + expect(ddl).not.toContain('resolved'); + expect(ddl).not.toContain('resolution'); + }); + }); + + it('given timestamps, should use DateTime64(3, UTC) everywhere', () => { + for (const { ddl } of ANALYTICS_TABLE_DDL) { + expect(flat(ddl)).toContain("`timestamp` DateTime64(3, 'UTC')"); + } + }); +}); + +describe('ensureAnalyticsTables — idempotent bootstrap shell', () => { + it('given a client, should run every table DDL through client.command', async () => { + const command = vi.fn().mockResolvedValue(undefined); + await ensureAnalyticsTables({ command }); + + expect(command).toHaveBeenCalledTimes(ANALYTICS_TABLE_DDL.length); + const queries = command.mock.calls.map((c) => (c[0] as { query: string }).query); + expect(queries).toEqual(ANALYTICS_TABLE_DDL.map((d) => d.ddl)); + }); + + it('given a failing DDL, should propagate the error (migrations fail loudly, unlike inserts)', async () => { + const command = vi.fn().mockRejectedValue(new Error('DDL rejected')); + await expect(ensureAnalyticsTables({ command })).rejects.toThrow('DDL rejected'); + }); +}); diff --git a/packages/lib/src/observability/__tests__/clickhouse-env.test.ts b/packages/lib/src/observability/__tests__/clickhouse-env.test.ts new file mode 100644 index 0000000000..09e562fd74 --- /dev/null +++ b/packages/lib/src/observability/__tests__/clickhouse-env.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from 'vitest'; +import { + resolveClickHouseMode, + resolveClickHouseGdprMode, + isClickHouseEnabledFlag, + type ClickHouseEnv, +} from '../clickhouse-env'; + +const enabledEnv = (overrides: Partial = {}): ClickHouseEnv => ({ + CLICKHOUSE_ENABLED: 'true', + CLICKHOUSE_HOST: 'https://my-cluster.clickhouse.cloud:8443', + CLICKHOUSE_USER: 'default', + CLICKHOUSE_PASSWORD: 'secret', + CLICKHOUSE_DATABASE: 'pagespace_analytics', + ...overrides, +}); + +describe('isClickHouseEnabledFlag', () => { + it("given the exact string 'true', should be enabled", () => { + expect(isClickHouseEnabledFlag('true')).toBe(true); + }); + + it.each([undefined, '', 'TRUE', '1', ' true ', 'false', 'yes'])( + 'given %j, should NOT be enabled (fail-closed, exact-match only)', + (flag) => { + expect(isClickHouseEnabledFlag(flag)).toBe(false); + }, + ); +}); + +describe('resolveClickHouseMode — three-state gating (#890 Phase 3)', () => { + describe('state 1: off (default)', () => { + it('given CLICKHOUSE_ENABLED unset, should be disabled — never throw, never misconfigured', () => { + const decision = resolveClickHouseMode({}); + expect(decision.mode).toBe('disabled'); + }); + + it('given CLICKHOUSE_ENABLED unset but full connection config present, should STILL be disabled (flag wins)', () => { + const decision = resolveClickHouseMode(enabledEnv({ CLICKHOUSE_ENABLED: undefined })); + expect(decision.mode).toBe('disabled'); + }); + + it("given CLICKHOUSE_ENABLED='false' with missing creds, should be disabled, NOT misconfigured", () => { + const decision = resolveClickHouseMode({ CLICKHOUSE_ENABLED: 'false' }); + expect(decision.mode).toBe('disabled'); + }); + }); + + describe('state 2: on + configured', () => { + it('given the flag on and HOST/USER/PASSWORD/DATABASE set, should be enabled with a resolved config', () => { + const decision = resolveClickHouseMode(enabledEnv()); + expect(decision.mode).toBe('enabled'); + if (decision.mode === 'enabled') { + expect(decision.config).toEqual({ + url: 'https://my-cluster.clickhouse.cloud:8443', + username: 'default', + password: 'secret', + database: 'pagespace_analytics', + }); + } + }); + + it('given a bare CLICKHOUSE_HOST (no scheme), should normalize to https on the ClickHouse Cloud port 8443', () => { + const decision = resolveClickHouseMode( + enabledEnv({ CLICKHOUSE_HOST: 'my-cluster.clickhouse.cloud' }), + ); + expect(decision.mode).toBe('enabled'); + if (decision.mode === 'enabled') { + expect(decision.config.url).toBe('https://my-cluster.clickhouse.cloud:8443'); + } + }); + + it('given an http:// CLICKHOUSE_HOST (local dev container), should keep it verbatim', () => { + const decision = resolveClickHouseMode( + enabledEnv({ CLICKHOUSE_HOST: 'http://localhost:8123' }), + ); + expect(decision.mode).toBe('enabled'); + if (decision.mode === 'enabled') { + expect(decision.config.url).toBe('http://localhost:8123'); + } + }); + + it('given CLICKHOUSE_URL set, should use it directly and not require CLICKHOUSE_HOST', () => { + const decision = resolveClickHouseMode({ + CLICKHOUSE_ENABLED: 'true', + CLICKHOUSE_URL: 'http://localhost:8123', + CLICKHOUSE_USER: 'user', + CLICKHOUSE_PASSWORD: 'password', + CLICKHOUSE_DATABASE: 'pagespace_analytics', + }); + expect(decision.mode).toBe('enabled'); + if (decision.mode === 'enabled') { + expect(decision.config.url).toBe('http://localhost:8123'); + } + }); + }); + + describe('state 3: on + misconfigured (fail-fast, never silently drop inserts)', () => { + it('given the flag on with no URL and no HOST, should be misconfigured naming the missing vars', () => { + const decision = resolveClickHouseMode({ CLICKHOUSE_ENABLED: 'true' }); + expect(decision.mode).toBe('misconfigured'); + if (decision.mode === 'misconfigured') { + expect(decision.reason).toContain('CLICKHOUSE_HOST'); + } + }); + + it.each(['CLICKHOUSE_USER', 'CLICKHOUSE_PASSWORD', 'CLICKHOUSE_DATABASE'] as const)( + 'given the flag on with %s missing, should be misconfigured naming that var', + (missing) => { + const decision = resolveClickHouseMode(enabledEnv({ [missing]: undefined })); + expect(decision.mode).toBe('misconfigured'); + if (decision.mode === 'misconfigured') { + expect(decision.reason).toContain(missing); + } + }, + ); + + it('given empty-string values, should treat them as unset (misconfigured)', () => { + const decision = resolveClickHouseMode(enabledEnv({ CLICKHOUSE_PASSWORD: '' })); + expect(decision.mode).toBe('misconfigured'); + }); + + it('given a CLICKHOUSE_HOST with a non-http(s) scheme, should be misconfigured', () => { + const decision = resolveClickHouseMode( + enabledEnv({ CLICKHOUSE_HOST: 'tcp://localhost:9000' }), + ); + expect(decision.mode).toBe('misconfigured'); + }); + + it('given a CLICKHOUSE_URL with a non-http(s) scheme, should be misconfigured', () => { + const decision = resolveClickHouseMode({ + CLICKHOUSE_ENABLED: 'true', + CLICKHOUSE_URL: 'tcp://localhost:9000', + CLICKHOUSE_USER: 'user', + CLICKHOUSE_PASSWORD: 'password', + CLICKHOUSE_DATABASE: 'pagespace_analytics', + }); + expect(decision.mode).toBe('misconfigured'); + }); + }); +}); + +describe('resolveClickHouseGdprMode — where subject data COULD live (#890 Phase 3 FIX, flag-rollback fail-open)', () => { + // GDPR export/erasure must consult CH whenever it is configured AT ALL — + // not only when the write-cutover flag is on. Toggling the flag off after + // rows landed in CH must not orphan them from Art 15/17. + it('given no CH env at all, should be unconfigured (CH never in play)', () => { + const decision = resolveClickHouseGdprMode({}); + expect(decision.mode).toBe('unconfigured'); + }); + + it('given full connection config with the flag OFF (rollback window), should be configured with the resolved config', () => { + const decision = resolveClickHouseGdprMode(enabledEnv({ CLICKHOUSE_ENABLED: undefined })); + expect(decision.mode).toBe('configured'); + if (decision.mode === 'configured') { + expect(decision.config.url).toBe('https://my-cluster.clickhouse.cloud:8443'); + expect(decision.config.database).toBe('pagespace_analytics'); + } + }); + + it('given full connection config with the flag ON, should be configured', () => { + expect(resolveClickHouseGdprMode(enabledEnv()).mode).toBe('configured'); + }); + + it('given the flag ON but connection config missing, should be misconfigured (fail-closed)', () => { + const decision = resolveClickHouseGdprMode({ CLICKHOUSE_ENABLED: 'true' }); + expect(decision.mode).toBe('misconfigured'); + }); + + it('given PARTIAL connection config with the flag OFF, should be misconfigured — CH may hold subject rows we cannot reach', () => { + const decision = resolveClickHouseGdprMode({ + CLICKHOUSE_HOST: 'my-cluster.clickhouse.cloud', + }); + expect(decision.mode).toBe('misconfigured'); + expect(decision.reason).toMatch(/CLICKHOUSE_PASSWORD/); + }); + + it('given an invalid scheme with the flag OFF, should be misconfigured, never unconfigured', () => { + const decision = resolveClickHouseGdprMode( + enabledEnv({ CLICKHOUSE_ENABLED: undefined, CLICKHOUSE_HOST: 'tcp://localhost:9000' }), + ); + expect(decision.mode).toBe('misconfigured'); + }); +}); diff --git a/packages/lib/src/observability/__tests__/clickhouse-integration.test.ts b/packages/lib/src/observability/__tests__/clickhouse-integration.test.ts new file mode 100644 index 0000000000..ed4e23c753 --- /dev/null +++ b/packages/lib/src/observability/__tests__/clickhouse-integration.test.ts @@ -0,0 +1,139 @@ +/** + * Live integration against the dev ClickHouse container (compose service + * `clickhouse`, #890 Phase 3 leaf 1). Skips itself when the container is not + * reachable so the unit suite stays green anywhere; run + * `docker compose up -d clickhouse` to exercise it for real. + * + * Proves the leaf-2 acceptance path end to end: idempotent DDL apply → + * adapter insert → flush → SELECT back. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { get } from 'node:http'; +import { createClient, type ClickHouseClient } from '@clickhouse/client'; +import { ensureAnalyticsTables } from '../clickhouse-ddl'; +import { createAnalyticsInserters } from '../analytics-inserts'; + +const CH_URL = process.env.CLICKHOUSE_URL ?? 'http://localhost:8123'; + +// node:http, not fetch — suite-level test setup stubs the global fetch. +const reachable = await new Promise((resolve) => { + const req = get(`${CH_URL}/ping`, { timeout: 2000 }, (res) => { + res.resume(); + resolve(res.statusCode === 200); + }); + req.on('timeout', () => req.destroy()); + req.on('error', () => resolve(false)); +}); + +describe.skipIf(!reachable)('ClickHouse container round-trip (DDL apply + adapter insert + SELECT back)', () => { + let client: ClickHouseClient; + const runId = `it-${Math.random().toString(36).slice(2)}`; + + beforeAll(async () => { + client = createClient({ + url: CH_URL, + username: process.env.CLICKHOUSE_USER ?? 'user', + password: process.env.CLICKHOUSE_PASSWORD ?? 'password', + database: process.env.CLICKHOUSE_DATABASE ?? 'pagespace_analytics', + }); + await ensureAnalyticsTables(client); + }); + + afterAll(async () => { + await client?.close(); + }); + + it('given the DDL already applied, applying again should be a no-op (idempotent)', async () => { + await expect(ensureAnalyticsTables(client)).resolves.toBeUndefined(); + }); + + it('given one row per table inserted via the adapters, each should SELECT back', async () => { + const inserters = createAnalyticsInserters({ getClient: () => client }); + const ts = new Date(); + + inserters.insertApiMetric({ + id: `${runId}-metric`, + timestamp: ts, + endpoint: '/api/integration', + method: 'GET', + statusCode: 200, + duration: 12, + cacheHit: true, + }); + inserters.insertSystemLog({ + id: `${runId}-log`, + timestamp: ts, + level: 'info', + message: 'integration round-trip', + category: 'api', + metadata: { runId }, + }); + inserters.insertUserActivity({ + id: `${runId}-activity`, + timestamp: ts, + userId: `${runId}-user`, + action: 'integration_test', + }); + inserters.insertError({ + id: `${runId}-error`, + timestamp: ts, + name: 'IntegrationError', + message: 'integration round-trip', + line: 1, + }); + await inserters.drain(); + + const selectById = async (table: string, id: string): Promise>> => { + const result = await client.query({ + query: `SELECT * FROM ${table} WHERE id = {id:String}`, + query_params: { id }, + format: 'JSONEachRow', + }); + return result.json>(); + }; + + const [metric] = await selectById('api_metrics', `${runId}-metric`); + expect(metric).toMatchObject({ endpoint: '/api/integration', status_code: 200, cache_hit: true }); + + const [log] = await selectById('system_logs', `${runId}-log`); + expect(log).toMatchObject({ level: 'info', category: 'api' }); + expect(JSON.parse(log.metadata as string)).toEqual({ runId }); + + const [activity] = await selectById('user_activities', `${runId}-activity`); + expect(activity).toMatchObject({ user_id: `${runId}-user`, action: 'integration_test' }); + + const [error] = await selectById('error_logs', `${runId}-error`); + expect(error).toMatchObject({ name: 'IntegrationError', line: 1 }); + }); + + it('given the created tables, engine metadata should match the design ref (ORDER BY, partition, TTL)', async () => { + const result = await client.query({ + query: ` + SELECT name, engine, sorting_key, partition_key + FROM system.tables + WHERE database = {db:String} + AND name IN ('api_metrics', 'system_logs', 'user_activities', 'error_logs') + `, + query_params: { db: process.env.CLICKHOUSE_DATABASE ?? 'pagespace_analytics' }, + format: 'JSONEachRow', + }); + const tables = await result.json<{ + name: string; + engine: string; + sorting_key: string; + partition_key: string; + }>(); + const byName = Object.fromEntries(tables.map((t) => [t.name, t])); + + expect(Object.keys(byName).sort()).toEqual(['api_metrics', 'error_logs', 'system_logs', 'user_activities']); + for (const table of Object.values(byName)) { + expect(table.engine).toBe('MergeTree'); + expect(table.partition_key).toBe('toYYYYMM(timestamp)'); + } + expect(byName.api_metrics.sorting_key).toBe('timestamp, endpoint, status_code'); + expect(byName.system_logs.sorting_key).toBe('timestamp, level, category'); + expect(byName.user_activities.sorting_key).toBe('user_id, timestamp, action'); + expect(byName.error_logs.sorting_key).toBe('timestamp, name'); + }); +}); diff --git a/packages/lib/src/observability/__tests__/error-resolutions.integration.test.ts b/packages/lib/src/observability/__tests__/error-resolutions.integration.test.ts new file mode 100644 index 0000000000..9dc8e5b3c3 --- /dev/null +++ b/packages/lib/src/observability/__tests__/error-resolutions.integration.test.ts @@ -0,0 +1,174 @@ +/** + * error_resolutions workflow against the REAL stores (#890 Phase 3 leaf 3). + * + * Proves the acceptance path end to end: the resolve action upserts the + * error_resolutions mini-table in main PG (migration 0194), and the + * two-step reader merges resolution state onto error rows fetched from + * whichever store is live — PG with the flag off, ClickHouse with it on — + * with identical merged output on identically seeded data. The two stores + * are never SQL-joined. + * + * Requires DATABASE_URL → migrated Postgres; the CH half additionally needs + * the dev ClickHouse container and is skipped without it. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { get } from 'node:http'; +import { db } from '@pagespace/db/db'; +import { like, inArray } from '@pagespace/db/operators'; +import { errorLogs, errorResolutions } from '@pagespace/db/schema/monitoring'; +import { getClickHouseClient, type ClickHouseClient } from '../clickhouse-client'; +import { ensureAnalyticsTables } from '../clickhouse-ddl'; +import { mapErrorLogToRow } from '../analytics-rows'; +import { + setErrorResolution, + fetchErrorResolutions, + listRecentErrorsWithResolutions, +} from '../error-resolutions'; + +const CH_URL = process.env.CLICKHOUSE_URL ?? 'http://localhost:8123'; +const CH_ENV = { + CLICKHOUSE_ENABLED: 'true', + CLICKHOUSE_URL: CH_URL, + CLICKHOUSE_USER: process.env.CLICKHOUSE_USER ?? 'user', + CLICKHOUSE_PASSWORD: process.env.CLICKHOUSE_PASSWORD ?? 'password', + CLICKHOUSE_DATABASE: process.env.CLICKHOUSE_DATABASE ?? 'pagespace_analytics', +}; + +// node:http, not fetch — the lib test setup stubs the global fetch. +const chReachable = await new Promise((resolve) => { + const req = get(`${CH_URL}/ping`, { timeout: 2000 }, (res) => { + res.resume(); + resolve(res.statusCode === 200); + }); + req.on('timeout', () => req.destroy()); + req.on('error', () => resolve(false)); +}); + +const pgReachable = await (async () => { + try { + await db.select({ errorId: errorResolutions.errorId }).from(errorResolutions).limit(1); + return true; + } catch { + return false; + } +})(); + +// Hour-aligned random window 36–72h back: inside every CH TTL (expired rows +// are dropped at insert time), in the past, and unique enough per run. +const WINDOW_START = new Date( + Math.floor((Date.now() - 72 * 3_600_000) / 3_600_000) * 3_600_000 + + Math.floor(Math.random() * 36) * 3_600_000, +); +const WINDOW = { + startDate: WINDOW_START, + endDate: new Date(WINDOW_START.getTime() + 3_600_000), +}; +const runId = `errres-${WINDOW_START.getTime()}-${Math.floor(Math.random() * 1e6)}`; +const at = (minutes: number): Date => new Date(WINDOW_START.getTime() + minutes * 60_000); + +async function withClickHouse(fn: () => Promise): Promise { + const previous: Record = {}; + for (const [key, value] of Object.entries(CH_ENV)) { + previous[key] = process.env[key]; + process.env[key] = value; + } + try { + return await fn(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +const errorRows = [ + { id: `${runId}-e1`, timestamp: at(5), name: 'TypeError', message: 'boom', stack: 'trace1', endpoint: '/api/x' }, + { id: `${runId}-e2`, timestamp: at(10), name: 'RangeError', message: 'bang' }, +] as const; + +let chClient: ClickHouseClient | null = null; + +describe.skipIf(!pgReachable)('error resolution workflow (real Postgres, optional ClickHouse)', () => { + beforeAll(async () => { + await db.insert(errorLogs).values(errorRows.map((r) => ({ + ...r, + stack: 'stack' in r ? r.stack : null, + endpoint: 'endpoint' in r ? r.endpoint : null, + }))); + if (chReachable) { + chClient = (await withClickHouse(async () => getClickHouseClient()))!; + await ensureAnalyticsTables(chClient); + await chClient.insert({ + table: 'error_logs', + values: errorRows.map((r) => mapErrorLogToRow(r, { id: r.id, timestamp: r.timestamp })), + format: 'JSONEachRow', + }); + } + }, 30_000); + + afterAll(async () => { + delete process.env.CLICKHOUSE_ENABLED; + try { + await db.delete(errorResolutions).where(like(errorResolutions.errorId, `${runId}%`)); + await db.delete(errorLogs).where(like(errorLogs.id, `${runId}%`)); + } catch { + // best-effort cleanup + } + try { + await chClient?.command({ query: `DELETE FROM error_logs WHERE id LIKE '${runId}%'` }); + } catch { + // lightweight deletes are best-effort + } + await chClient?.close(); + }, 30_000); + + it('given a resolve action, should upsert exactly one PG row and update it on re-resolve (reopen)', async () => { + await setErrorResolution({ errorId: `${runId}-e1`, resolvedBy: 'admin-1', resolution: 'fixed' }); + let rows = await fetchErrorResolutions([`${runId}-e1`]); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ errorId: `${runId}-e1`, resolved: true, resolvedBy: 'admin-1', resolution: 'fixed' }); + + await setErrorResolution({ errorId: `${runId}-e1`, resolved: false, resolvedBy: 'admin-2', resolution: 'still happening' }); + rows = await fetchErrorResolutions([`${runId}-e1`]); + expect(rows).toHaveLength(1); // updated in place, not duplicated + expect(rows[0]).toMatchObject({ resolved: false, resolvedBy: 'admin-2', resolution: 'still happening' }); + + const [{ count }] = await db + .select({ count: errorResolutions.errorId }) + .from(errorResolutions) + .where(inArray(errorResolutions.errorId, [`${runId}-e1`])) + .then((r) => [{ count: r.length }]); + expect(count).toBe(1); + }); + + it('given the flag off, should merge PG error rows with PG resolutions', async () => { + await setErrorResolution({ errorId: `${runId}-e1`, resolvedBy: 'admin-1', resolution: 'fixed' }); + const merged = await listRecentErrorsWithResolutions(WINDOW, 20); + const mine = merged.filter((e) => e.id.startsWith(runId)); + + expect(mine.map((e) => e.id)).toEqual([`${runId}-e2`, `${runId}-e1`]); // timestamp desc + expect(mine[1]).toMatchObject({ resolved: true, resolvedBy: 'admin-1', resolution: 'fixed' }); + expect(mine[0]).toMatchObject({ resolved: false, resolvedAt: null, resolvedBy: null, resolution: null }); + }); + + it.skipIf(!chReachable)( + 'given the flag on, should fetch errors from CH, resolutions from PG, and merge to the same output', + async () => { + await setErrorResolution({ errorId: `${runId}-e1`, resolvedBy: 'admin-1', resolution: 'fixed' }); + const pgMerged = (await listRecentErrorsWithResolutions(WINDOW, 20)).filter((e) => e.id.startsWith(runId)); + const chMerged = (await withClickHouse(() => listRecentErrorsWithResolutions(WINDOW, 20))).filter((e) => + e.id.startsWith(runId), + ); + + const normalize = (rows: typeof pgMerged) => + rows.map((e) => ({ + ...e, + timestamp: e.timestamp.getTime(), + resolvedAt: e.resolvedAt?.getTime() ?? null, + })); + expect(normalize(chMerged)).toEqual(normalize(pgMerged)); + expect(chMerged[1].resolved).toBe(true); + }, + ); +}); diff --git a/packages/lib/src/observability/__tests__/error-resolutions.test.ts b/packages/lib/src/observability/__tests__/error-resolutions.test.ts new file mode 100644 index 0000000000..d9b070f14b --- /dev/null +++ b/packages/lib/src/observability/__tests__/error-resolutions.test.ts @@ -0,0 +1,128 @@ +/** + * Error resolution workflow (#890 Phase 3 leaf 3). + * + * error_logs rows move to immutable ClickHouse post-cutover, so the mutable + * resolved-flag workflow lives in the error_resolutions mini-table in main + * PG, keyed by the error row's stable id. The two stores are NEVER + * SQL-joined: readers fetch error rows (CH or PG), fetch resolutions by id + * from PG, and merge in app code. These tests pin the pure merge/normalize + * logic and the two-step composition; the PG store shell is exercised by the + * app-level parity integration tests. + */ +import { describe, it, expect, vi } from 'vitest'; +import { + mergeErrorResolutions, + normalizeResolutionInput, + createErrorResolutionReader, + type ErrorResolutionRecord, +} from '../error-resolutions'; + +const NOW = new Date('2026-07-10T00:00:00.000Z'); + +const resolution = (overrides?: Partial): ErrorResolutionRecord => ({ + errorId: 'e1', + resolved: true, + resolvedAt: NOW, + resolvedBy: 'admin-1', + resolution: 'fixed upstream', + ...overrides, +}); + +describe('mergeErrorResolutions', () => { + it('given a resolution for an error id, should attach its fields to that error', () => { + const merged = mergeErrorResolutions( + [{ id: 'e1', message: 'boom' }, { id: 'e2', message: 'bang' }], + [resolution()], + ); + expect(merged).toEqual([ + { + id: 'e1', + message: 'boom', + resolved: true, + resolvedAt: NOW, + resolvedBy: 'admin-1', + resolution: 'fixed upstream', + }, + { + id: 'e2', + message: 'bang', + resolved: false, + resolvedAt: null, + resolvedBy: null, + resolution: null, + }, + ]); + }); + + it('given a resolution row whose id matches no error, should ignore it', () => { + const merged = mergeErrorResolutions([{ id: 'e2' }], [resolution({ errorId: 'ghost' })]); + expect(merged).toEqual([ + { id: 'e2', resolved: false, resolvedAt: null, resolvedBy: null, resolution: null }, + ]); + }); + + it('given a reopened resolution (resolved=false), should carry it through, not default it', () => { + const merged = mergeErrorResolutions( + [{ id: 'e1' }], + [resolution({ resolved: false, resolution: 'reopened: still happening' })], + ); + expect(merged[0].resolved).toBe(false); + expect(merged[0].resolution).toBe('reopened: still happening'); + expect(merged[0].resolvedAt).toEqual(NOW); + }); +}); + +describe('normalizeResolutionInput', () => { + it('given only an errorId, should default to resolved=true with null attribution', () => { + expect(normalizeResolutionInput({ errorId: 'e1' }, NOW)).toEqual({ + errorId: 'e1', + resolved: true, + resolvedAt: NOW, + resolvedBy: null, + resolution: null, + }); + }); + + it('given explicit fields, should keep them (including resolved=false reopen)', () => { + expect( + normalizeResolutionInput( + { errorId: 'e1', resolved: false, resolvedBy: 'admin-1', resolution: 'not fixed' }, + NOW, + ), + ).toEqual({ + errorId: 'e1', + resolved: false, + resolvedAt: NOW, + resolvedBy: 'admin-1', + resolution: 'not fixed', + }); + }); +}); + +describe('createErrorResolutionReader', () => { + it('given errors from the error store, should fetch resolutions for exactly those ids and merge', async () => { + const errors = [ + { id: 'e1', message: 'boom' }, + { id: 'e2', message: 'bang' }, + ]; + const fetchErrors = vi.fn(async () => errors); + const fetchResolutions = vi.fn(async () => [resolution()]); + const reader = createErrorResolutionReader({ fetchErrors, fetchResolutions }); + + const merged = await reader({ startDate: NOW }, 20); + + expect(fetchErrors).toHaveBeenCalledWith({ startDate: NOW }, 20); + expect(fetchResolutions).toHaveBeenCalledWith(['e1', 'e2']); + expect(merged[0].resolved).toBe(true); + expect(merged[1].resolved).toBe(false); + }); + + it('given no errors in the window, should not query resolutions at all', async () => { + const fetchErrors = vi.fn(async () => []); + const fetchResolutions = vi.fn(async () => []); + const reader = createErrorResolutionReader({ fetchErrors, fetchResolutions }); + + expect(await reader({}, 20)).toEqual([]); + expect(fetchResolutions).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/lib/src/observability/analytics-gdpr.ts b/packages/lib/src/observability/analytics-gdpr.ts new file mode 100644 index 0000000000..03b3f698cd --- /dev/null +++ b/packages/lib/src/observability/analytics-gdpr.ts @@ -0,0 +1,202 @@ +/** + * GDPR surfaces for the ClickHouse analytics tier (#890 Phase 3 leaf 4). + * + * With CLICKHOUSE_ENABLED, new analytics rows land only in CH — so Art 15 + * export must read the subject's CH rows and Art 17 erasure must remove them. + * error_logs deliberately has NO TTL (design ruling), which makes these + * erasure mutations its ONLY eraser — they are not optional hygiene. + * + * Pure core: query/mutation builders (named params, never interpolated) and + * JSONEachRow parsers to the exact shapes gdpr-export.ts produces from PG. + * Column selection mirrors the PG collectors' deliberate exclusions — + * ip/user_agent/session_id/request_id/stack/metadata are internal diagnostic + * telemetry, not data about the subject's own activity. + * + * Shells: thin client wrappers. Errors PROPAGATE in both directions — an + * incomplete export and a skipped erasure are both compliance failures, so + * this path is fail-closed (unlike the never-throw insert adapters). + * + * Erasure uses lightweight DELETE (DELETE FROM … WHERE), the appropriate + * mechanism for MergeTree: rows become invisible to queries as soon as the + * statement completes; physical removal follows in background merges. + */ +import type { ClickHouseClient } from '@clickhouse/client'; +import { ANALYTICS_TABLES } from './analytics-rows'; +import { chDateTime, emptyToNull, type ChQuery } from './analytics-read-core'; + +// ── Export row shapes (PG-parity: gdpr-export.ts User*Export minus PG-only fields) ── + +export interface ChUserSystemLogRow { + id: string; + timestamp: Date; + level: string; + message: string; + category: string | null; + endpoint: string | null; + method: string | null; + duration: number | null; +} + +export interface ChUserApiMetricRow { + id: string; + timestamp: Date; + endpoint: string; + method: string; + statusCode: number; + duration: number; + requestSize: number | null; + responseSize: number | null; +} + +/** No resolution state here — that lives in the error_resolutions PG mini-table. */ +export interface ChUserErrorLogRow { + id: string; + timestamp: Date; + name: string; + message: string; + endpoint: string | null; + method: string | null; + file: string | null; + line: number | null; + column: number | null; +} + +// ── Pure core: builders ────────────────────────────────────────────────────── + +const bySubject = (columns: string, table: string): string => ` + SELECT ${columns} + FROM ${table} + WHERE user_id = {userId: String} + ORDER BY timestamp + `; + +export const buildUserSystemLogsExportQuery = (userId: string): ChQuery => ({ + query: bySubject('id, timestamp, level, message, category, endpoint, method, duration', ANALYTICS_TABLES.systemLogs), + query_params: { userId }, +}); + +export const buildUserApiMetricsExportQuery = (userId: string): ChQuery => ({ + query: bySubject('id, timestamp, endpoint, method, status_code, duration, request_size, response_size', ANALYTICS_TABLES.apiMetrics), + query_params: { userId }, +}); + +export const buildUserErrorLogsExportQuery = (userId: string): ChQuery => ({ + query: bySubject('id, timestamp, name, message, endpoint, method, file, line, `column`', ANALYTICS_TABLES.errorLogs), + query_params: { userId }, +}); + +/** One lightweight DELETE per analytics table — the full Art 17 erasure set. */ +export const buildUserErasureDeletes = (userId: string): ChQuery[] => + Object.values(ANALYTICS_TABLES).map((table) => ({ + query: `DELETE FROM ${table} WHERE user_id = {userId: String}`, + query_params: { userId }, + })); + +/** + * The subject's error ids — the join keys into the error_resolutions PG + * mini-table. Erasure must collect these BEFORE deleting the CH error rows: + * the delete destroys the only link, leaving the resolution notes as + * unreachable orphans (#890 Phase 3 FIX). + */ +export const buildUserErrorIdsQuery = (userId: string): ChQuery => ({ + query: `SELECT id FROM ${ANALYTICS_TABLES.errorLogs} WHERE user_id = {userId: String}`, + query_params: { userId }, +}); + +export const parseUserErrorIdRows = (rows: unknown[]): string[] => + (rows as Array<{ id: string }>).map((r) => r.id); + +// ── Pure core: parsers ─────────────────────────────────────────────────────── + +export const parseUserSystemLogExportRows = (rows: unknown[]): ChUserSystemLogRow[] => + (rows as Array<{ + id: string; timestamp: string; level: string; message: string; + category: string | null; endpoint: string | null; method: string | null; duration: number | null; + }>).map((r) => ({ + id: r.id, + timestamp: chDateTime(r.timestamp), + level: r.level, + message: r.message, + // Un-map the leaf-2 write mapping: PG NULL category was stored as ''. + category: emptyToNull(r.category), + endpoint: r.endpoint, + method: r.method, + duration: r.duration, + })); + +export const parseUserApiMetricExportRows = (rows: unknown[]): ChUserApiMetricRow[] => + (rows as Array<{ + id: string; timestamp: string; endpoint: string; method: string; + status_code: number; duration: number; request_size: number | null; response_size: number | null; + }>).map((r) => ({ + id: r.id, + timestamp: chDateTime(r.timestamp), + endpoint: r.endpoint, + method: r.method, + statusCode: r.status_code, + duration: r.duration, + requestSize: r.request_size, + responseSize: r.response_size, + })); + +export const parseUserErrorLogExportRows = (rows: unknown[]): ChUserErrorLogRow[] => + (rows as Array<{ + id: string; timestamp: string; name: string; message: string; + endpoint: string | null; method: string | null; file: string | null; + line: number | null; column: number | null; + }>).map((r) => ({ + id: r.id, + timestamp: chDateTime(r.timestamp), + name: r.name, + message: r.message, + endpoint: r.endpoint, + method: r.method, + file: r.file, + line: r.line, + column: r.column, + })); + +// ── Shells ─────────────────────────────────────────────────────────────────── + +const selectRows = async (client: ClickHouseClient, built: ChQuery): Promise => { + const resultSet = await client.query({ + query: built.query, + query_params: built.query_params, + format: 'JSONEachRow', + }); + return resultSet.json(); +}; + +export const collectChUserSystemLogs = async ( + client: ClickHouseClient, + userId: string, +): Promise => + parseUserSystemLogExportRows(await selectRows(client, buildUserSystemLogsExportQuery(userId))); + +export const collectChUserApiMetrics = async ( + client: ClickHouseClient, + userId: string, +): Promise => + parseUserApiMetricExportRows(await selectRows(client, buildUserApiMetricsExportQuery(userId))); + +export const collectChUserErrorLogs = async ( + client: ClickHouseClient, + userId: string, +): Promise => + parseUserErrorLogExportRows(await selectRows(client, buildUserErrorLogsExportQuery(userId))); + +export const collectChUserErrorIds = async ( + client: ClickHouseClient, + userId: string, +): Promise => + parseUserErrorIdRows(await selectRows(client, buildUserErrorIdsQuery(userId))); + +/** Art 17 erasure across all 4 analytics tables. Sequential and fail-closed. */ +export const deleteChUserAnalytics = async ( + client: ClickHouseClient, + userId: string, +): Promise => { + for (const built of buildUserErasureDeletes(userId)) { + await client.command({ query: built.query, query_params: built.query_params }); + } +}; diff --git a/packages/lib/src/observability/analytics-inserts.ts b/packages/lib/src/observability/analytics-inserts.ts new file mode 100644 index 0000000000..a6f313627f --- /dev/null +++ b/packages/lib/src/observability/analytics-inserts.ts @@ -0,0 +1,169 @@ +/** + * ClickHouse insert adapters for the 4 analytics tables (#890 Phase 3 leaf 2). + * + * Each adapter is (row) → void: it maps the PG-shaped write to a CH row (pure, + * analytics-rows.ts) and hands it to that table's buffer + * (clickhouse-buffer.ts). No DB access happens inside the call — the buffer + * flushes asynchronously — so the request path never blocks on ClickHouse. + * + * Never-throw contract: a misconfigured client, an unreachable server, or + * unserializable metadata drops the row and logs a payload-free message + * (table name + error only — rows can carry PII). Analytics rows are + * droppable; requests are not. + */ + +import { createId } from '@paralleldrive/cuid2'; +import { + createClickHouseBuffer, + type ClickHouseBuffer, +} from './clickhouse-buffer'; +import { getClickHouseClient } from './clickhouse-client'; +import { ClickHouseMisconfiguredError } from './clickhouse-env'; +import { + ANALYTICS_TABLES, + mapApiMetricToRow, + mapErrorLogToRow, + mapSystemLogToRow, + mapUserActivityToRow, + type AnalyticsTable, + type ApiMetricWrite, + type AssignedRowIdentity, + type ErrorLogWrite, + type SystemLogWrite, + type UserActivityWrite, +} from './analytics-rows'; + +export type { + ApiMetricWrite, + SystemLogWrite, + UserActivityWrite, + ErrorLogWrite, +} from './analytics-rows'; + +/** Structural slice of ClickHouseClient — the adapters need only insert(). */ +export interface AnalyticsInsertClient { + insert: (params: { + table: string; + values: Record[]; + format: 'JSONEachRow'; + }) => Promise; +} + +export interface AnalyticsInsertersDeps { + /** Returns null when the tier is off; may throw when misconfigured (absorbed). */ + getClient: () => AnalyticsInsertClient | null; + createId?: () => string; + now?: () => Date; + /** Receives payload-free messages on dropped rows/batches. Defaults to console.error. */ + logError?: (message: string) => void; + maxRows?: number; + flushIntervalMs?: number; +} + +export interface AnalyticsInserters { + insertApiMetric: (row: ApiMetricWrite) => void; + insertSystemLog: (row: SystemLogWrite) => void; + insertUserActivity: (row: UserActivityWrite) => void; + insertError: (row: ErrorLogWrite) => void; + /** Flush all table buffers now (tests, opportunistic). Never rejects. */ + flush: () => Promise; + /** Flush and await in-flight inserts (shutdown path). Never rejects. */ + drain: () => Promise; +} + +export function createAnalyticsInserters(deps: AnalyticsInsertersDeps): AnalyticsInserters { + const generateId = deps.createId ?? createId; + const now = deps.now ?? (() => new Date()); + const logError = deps.logError ?? ((message: string) => console.error(message)); + + // One buffer per table, created on first use — importing this module (or + // creating the inserters) touches neither the client nor the env. + const buffers = new Map>>(); + + const getBuffer = (table: AnalyticsTable): ClickHouseBuffer> | null => { + const existing = buffers.get(table); + if (existing) return existing; + + const client = deps.getClient(); + if (!client) return null; + + const buffer = createClickHouseBuffer>(table, { + insert: async ({ table: t, values }) => { + await client.insert({ table: t, values, format: 'JSONEachRow' }); + }, + maxRows: deps.maxRows, + flushIntervalMs: deps.flushIntervalMs, + logError, + }); + buffers.set(table, buffer); + return buffer; + }; + + // buildRow runs inside the try so mapping failures (e.g. circular metadata) + // are absorbed like client failures: drop + payload-free log. + const enqueue = (table: AnalyticsTable, buildRow: () => Record): void => { + try { + const buffer = getBuffer(table); + if (!buffer) return; + buffer.insert(buildRow()); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // A misconfigured deploy is not a transient drop — the startup probe + // (probeClickHouseStartup at each composition root) should have crashed + // this process; name the state so it can never read as routine noise. + logError( + error instanceof ClickHouseMisconfiguredError + ? `analytics-inserts: MISCONFIGURED ClickHouse — dropping "${table}" rows; the startup probe should have crashed this process: ${message}` + : `analytics-inserts: dropped a "${table}" row: ${message}`, + ); + } + }; + + const assign = (id: string | undefined, timestamp: Date | undefined): AssignedRowIdentity => ({ + id: id ?? generateId(), + timestamp: timestamp ?? now(), + }); + + return { + insertApiMetric(row: ApiMetricWrite): void { + enqueue(ANALYTICS_TABLES.apiMetrics, () => + mapApiMetricToRow(row, assign(row.id, row.timestamp))); + }, + insertSystemLog(row: SystemLogWrite): void { + enqueue(ANALYTICS_TABLES.systemLogs, () => + mapSystemLogToRow(row, assign(row.id, row.timestamp))); + }, + insertUserActivity(row: UserActivityWrite): void { + enqueue(ANALYTICS_TABLES.userActivities, () => + mapUserActivityToRow(row, assign(row.id, row.timestamp))); + }, + insertError(row: ErrorLogWrite): void { + enqueue(ANALYTICS_TABLES.errorLogs, () => + mapErrorLogToRow(row, assign(row.id, row.timestamp))); + }, + async flush(): Promise { + await Promise.all([...buffers.values()].map((buffer) => buffer.flush())); + }, + async drain(): Promise { + await Promise.all([...buffers.values()].map((buffer) => buffer.drain())); + }, + }; +} + +// Per-process default instance wired to the lazy client shell — the writers in +// logging/logger-database.ts (and the web write sites) import these directly. +const defaultInserters = createAnalyticsInserters({ getClient: getClickHouseClient }); + +export const insertApiMetric = (row: ApiMetricWrite): void => + defaultInserters.insertApiMetric(row); +export const insertSystemLog = (row: SystemLogWrite): void => + defaultInserters.insertSystemLog(row); +export const insertUserActivity = (row: UserActivityWrite): void => + defaultInserters.insertUserActivity(row); +export const insertError = (row: ErrorLogWrite): void => + defaultInserters.insertError(row); + +/** Flush the default buffers (opportunistic; never rejects). */ +export const flushAnalyticsInserts = (): Promise => defaultInserters.flush(); +/** Drain the default buffers on shutdown (never rejects). */ +export const drainAnalyticsInserts = (): Promise => defaultInserters.drain(); diff --git a/packages/lib/src/observability/analytics-read-core.ts b/packages/lib/src/observability/analytics-read-core.ts new file mode 100644 index 0000000000..625c0defd3 --- /dev/null +++ b/packages/lib/src/observability/analytics-read-core.ts @@ -0,0 +1,528 @@ +/** + * Pure core of the ClickHouse analytics READ path (#890 Phase 3 leaf 3). + * + * The admin/web monitoring readers re-point to ClickHouse post-cutover; this + * module holds every piece of that path that is logic rather than I/O: + * query builders (SQL text + named params — dynamic values are ALWAYS bound, + * never interpolated) and row parsers that convert JSONEachRow output back + * to the exact runtime shapes the old PG queries produced (Date objects for + * timestamps/buckets, numbers for counts — CH serializes UInt64 as strings — + * and null where PG returned NULL, including the system_logs.category '' + * stand-in introduced by the leaf-2 write mapping). + * + * Aggregations run in CH SQL (its strength), shaped to exploit the MergeTree + * ORDER BY keys from clickhouse-ddl.ts: api_metrics (timestamp, endpoint, + * status_code) · system_logs (timestamp, level, category) · user_activities + * (user_id, timestamp, action) · error_logs (timestamp, name). + * + * The execution shells live in analytics-reads.ts. + */ + +import { toClickHouseDateTime64 } from './analytics-rows'; + +export interface TimeWindow { + startDate?: Date; + endDate?: Date; +} + +export interface ChQuery { + query: string; + query_params: Record; +} + +// ── Value coercion (JSONEachRow → PG-parity runtime values) ────────────────── + +/** count()/countIf() come back as UInt64 strings; missing → 0. */ +export const chCount = (value: unknown): number => + value === null || value === undefined ? 0 : Number(value); + +/** avg()/max()/min() — Float64 numbers, or null (CH serializes nan as null). */ +export const chNullableNumber = (value: unknown): number | null => + value === null || value === undefined ? null : Number(value); + +/** DateTime / DateTime64(3) rendered in the column's UTC timezone → Date. */ +export const chDateTime = (value: string): Date => new Date(`${value.replace(' ', 'T')}Z`); + +/** Un-map the leaf-2 write mapping: PG NULL category was stored as '' in CH. */ +export const emptyToNull = (value: string | null): string | null => + value === '' || value === null ? null : value; + +/** jsonb columns were serialized to CH String; parse back to the jsonb Record shape. */ +export const parseChMetadata = (value: string | null): Record | null => { + if (value === null) return null; + try { + const parsed: unknown = JSON.parse(value); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + return null; + } catch { + return null; + } +}; + +// ── Shared window fragment ─────────────────────────────────────────────────── + +const windowFragment = ( + window: TimeWindow, +): { conditions: string[]; params: Record } => { + const conditions: string[] = []; + const params: Record = {}; + if (window.startDate) { + conditions.push('timestamp >= {tw_start: DateTime64(3)}'); + params.tw_start = toClickHouseDateTime64(window.startDate); + } + if (window.endDate) { + conditions.push('timestamp <= {tw_end: DateTime64(3)}'); + params.tw_end = toClickHouseDateTime64(window.endDate); + } + return { conditions, params }; +}; + +const whereClause = (conditions: string[]): string => + conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + +// ── system_logs ────────────────────────────────────────────────────────────── + +export const buildLogsByLevelQuery = (window: TimeWindow): ChQuery => { + const { conditions, params } = windowFragment(window); + return { + query: ` + SELECT level, count() AS count + FROM system_logs + ${whereClause(conditions)} + GROUP BY level + `, + query_params: params, + }; +}; + +export interface LogsByLevelRow { + level: string; + count: number; +} + +export const parseLogsByLevelRows = (rows: unknown[]): LogsByLevelRow[] => + (rows as Array<{ level: string; count: unknown }>).map((r) => ({ + level: r.level, + count: chCount(r.count), + })); + +export const buildErrorTrendsQuery = (window: TimeWindow): ChQuery => { + const { conditions, params } = windowFragment(window); + return { + query: ` + SELECT toStartOfHour(timestamp) AS hour, category, count() AS count + FROM system_logs + ${whereClause(["level = 'error'", ...conditions])} + GROUP BY hour, category + ORDER BY hour DESC + LIMIT {limit: UInt32} + `, + query_params: { ...params, limit: 168 }, + }; +}; + +export interface ErrorTrendRow { + hour: Date; + category: string | null; + count: number; +} + +export const parseErrorTrendRows = (rows: unknown[]): ErrorTrendRow[] => + (rows as Array<{ hour: string; category: string | null; count: unknown }>).map((r) => ({ + hour: chDateTime(r.hour), + category: emptyToNull(r.category), + count: chCount(r.count), + })); + +export const buildFailedLoginsQuery = (window: TimeWindow): ChQuery => { + const { conditions, params } = windowFragment(window); + return { + query: ` + SELECT timestamp, ip, metadata + FROM system_logs + ${whereClause(["category = 'auth'", "level IN ('warn', 'error')", ...conditions])} + ORDER BY timestamp DESC + LIMIT {limit: UInt32} + `, + query_params: { ...params, limit: 25 }, + }; +}; + +export interface FailedLoginRow { + timestamp: Date; + ip: string | null; + metadata: Record | null; +} + +export const parseFailedLoginRows = (rows: unknown[]): FailedLoginRow[] => + (rows as Array<{ timestamp: string; ip: string | null; metadata: string | null }>).map( + (r) => ({ + timestamp: chDateTime(r.timestamp), + ip: r.ip, + metadata: parseChMetadata(r.metadata), + }), + ); + +// ── error_logs ─────────────────────────────────────────────────────────────── + +export const buildRecentErrorsQuery = (window: TimeWindow, limit: number): ChQuery => { + const { conditions, params } = windowFragment(window); + return { + query: ` + SELECT id, timestamp, message, name AS error_name, stack, endpoint, user_id + FROM error_logs + ${whereClause(conditions)} + ORDER BY timestamp DESC + LIMIT {limit: UInt32} + `, + query_params: { ...params, limit }, + }; +}; + +export interface RecentErrorRow { + id: string; + timestamp: Date; + message: string; + errorName: string; + errorMessage: string | null; + endpoint: string | null; + userId: string | null; +} + +export const parseRecentErrorRows = (rows: unknown[]): RecentErrorRow[] => + ( + rows as Array<{ + id: string; + timestamp: string; + message: string; + error_name: string; + stack: string | null; + endpoint: string | null; + user_id: string | null; + }> + ).map((r) => ({ + id: r.id, + timestamp: chDateTime(r.timestamp), + message: r.message, + errorName: r.error_name, + errorMessage: r.stack, + endpoint: r.endpoint, + userId: r.user_id, + })); + +export const buildErrorPatternsQuery = (window: TimeWindow): ChQuery => { + const { conditions, params } = windowFragment(window); + return { + query: ` + SELECT name, endpoint, count() AS count + FROM error_logs + ${whereClause(conditions)} + GROUP BY name, endpoint + ORDER BY count DESC + LIMIT {limit: UInt32} + `, + query_params: { ...params, limit: 20 }, + }; +}; + +export interface ErrorPatternRow { + name: string; + endpoint: string | null; + count: number; +} + +export const parseErrorPatternRows = (rows: unknown[]): ErrorPatternRow[] => + (rows as Array<{ name: string; endpoint: string | null; count: unknown }>).map((r) => ({ + name: r.name, + endpoint: r.endpoint, + count: chCount(r.count), + })); + +// ── api_metrics ────────────────────────────────────────────────────────────── + +export const buildVolumeOverTimeQuery = (window: TimeWindow): ChQuery => { + const { conditions, params } = windowFragment(window); + return { + query: ` + SELECT toStartOfHour(timestamp) AS hour, count() AS count, avg(duration) AS avg_response_time + FROM api_metrics + ${whereClause(conditions)} + GROUP BY hour + ORDER BY hour DESC + LIMIT {limit: UInt32} + `, + query_params: { ...params, limit: 168 }, + }; +}; + +export interface VolumeOverTimeRow { + hour: Date; + count: number; + avg_response_time: number | null; +} + +export const parseVolumeOverTimeRows = (rows: unknown[]): VolumeOverTimeRow[] => + (rows as Array<{ hour: string; count: unknown; avg_response_time: unknown }>).map((r) => ({ + hour: chDateTime(r.hour), + count: chCount(r.count), + avg_response_time: chNullableNumber(r.avg_response_time), + })); + +export const buildTopEndpointsQuery = (window: TimeWindow): ChQuery => { + const { conditions, params } = windowFragment(window); + return { + query: ` + SELECT endpoint, count() AS count, avg(duration) AS avg_response_time + FROM api_metrics + ${whereClause(conditions)} + GROUP BY endpoint + ORDER BY count DESC + LIMIT {limit: UInt32} + `, + query_params: { ...params, limit: 10 }, + }; +}; + +export interface TopEndpointRow { + endpoint: string; + count: number; + avgResponseTime: number | null; +} + +export const parseTopEndpointRows = (rows: unknown[]): TopEndpointRow[] => + (rows as Array<{ endpoint: string; count: unknown; avg_response_time: unknown }>).map( + (r) => ({ + endpoint: r.endpoint, + count: chCount(r.count), + avgResponseTime: chNullableNumber(r.avg_response_time), + }), + ); + +export const buildRequestErrorCountsQuery = (window: TimeWindow): ChQuery => { + const { conditions, params } = windowFragment(window); + return { + query: ` + SELECT count() AS total, countIf(status_code >= 400) AS errors + FROM api_metrics + ${whereClause(conditions)} + `, + query_params: params, + }; +}; + +export interface RequestErrorCounts { + total: number; + errors: number; +} + +export const parseRequestErrorCounts = (rows: unknown[]): RequestErrorCounts => { + const row = (rows as Array<{ total: unknown; errors: unknown }>)[0]; + return { total: chCount(row?.total), errors: chCount(row?.errors) }; +}; + +export const buildResponseTimesQuery = (window: TimeWindow): ChQuery => { + const { conditions, params } = windowFragment(window); + return { + query: ` + SELECT + toStartOfHour(timestamp) AS hour, + avg(duration) AS avg_response_time, + max(duration) AS max_response_time, + min(duration) AS min_response_time + FROM api_metrics + ${whereClause(conditions)} + GROUP BY hour + ORDER BY hour DESC + LIMIT {limit: UInt32} + `, + query_params: { ...params, limit: 48 }, + }; +}; + +export interface ResponseTimeRow { + hour: Date; + avg_response_time: number | null; + max_response_time: number | null; + min_response_time: number | null; +} + +export const parseResponseTimeRows = (rows: unknown[]): ResponseTimeRow[] => + ( + rows as Array<{ + hour: string; + avg_response_time: unknown; + max_response_time: unknown; + min_response_time: unknown; + }> + ).map((r) => ({ + hour: chDateTime(r.hour), + avg_response_time: chNullableNumber(r.avg_response_time), + max_response_time: chNullableNumber(r.max_response_time), + min_response_time: chNullableNumber(r.min_response_time), + })); + +export const buildSlowQueriesQuery = ( + window: TimeWindow, + thresholdMs = 5000, + limit = 20, +): ChQuery => { + const { conditions, params } = windowFragment(window); + return { + query: ` + SELECT endpoint, duration AS response_time, timestamp, user_id + FROM api_metrics + ${whereClause(['duration >= {slow_ms: Int32}', ...conditions])} + ORDER BY duration DESC + LIMIT {limit: UInt32} + `, + query_params: { ...params, slow_ms: thresholdMs, limit }, + }; +}; + +export interface SlowQueryRow { + endpoint: string; + responseTime: number; + timestamp: Date; + userId: string | null; +} + +export const parseSlowQueryRows = (rows: unknown[]): SlowQueryRow[] => + ( + rows as Array<{ + endpoint: string; + response_time: number; + timestamp: string; + user_id: string | null; + }> + ).map((r) => ({ + endpoint: r.endpoint, + responseTime: Number(r.response_time), + timestamp: chDateTime(r.timestamp), + userId: r.user_id, + })); + +export const buildEndpointPerformanceQuery = (window: TimeWindow): ChQuery => { + const { conditions, params } = windowFragment(window); + return { + query: ` + SELECT endpoint AS metric, avg(duration) AS avg_value, count() AS count + FROM api_metrics + ${whereClause(conditions)} + GROUP BY endpoint + ORDER BY count DESC + `, + query_params: params, + }; +}; + +export interface EndpointPerformanceRow { + metric: string; + avgValue: number | null; + count: number; +} + +export const parseEndpointPerformanceRows = (rows: unknown[]): EndpointPerformanceRow[] => + (rows as Array<{ metric: string; avg_value: unknown; count: unknown }>).map((r) => ({ + metric: r.metric, + avgValue: chNullableNumber(r.avg_value), + count: chCount(r.count), + })); + +// ── user_activities ────────────────────────────────────────────────────────── + +export const buildActivityHeatmapQuery = (window: TimeWindow): ChQuery => { + const { conditions, params } = windowFragment(window); + return { + query: ` + SELECT + toDayOfWeek(timestamp) % 7 AS day_of_week, + toHour(timestamp) AS hour_of_day, + count() AS activity_count + FROM user_activities + ${whereClause(conditions)} + GROUP BY day_of_week, hour_of_day + `, + query_params: params, + }; +}; + +export interface ActivityHeatmapRow { + day_of_week: number; + hour_of_day: number; + activity_count: number; +} + +export const parseActivityHeatmapRows = (rows: unknown[]): ActivityHeatmapRow[] => + (rows as Array<{ day_of_week: unknown; hour_of_day: unknown; activity_count: unknown }>).map( + (r) => ({ + day_of_week: Number(r.day_of_week), + hour_of_day: Number(r.hour_of_day), + activity_count: chCount(r.activity_count), + }), + ); + +export const buildMostActiveUsersQuery = (window: TimeWindow, limit: number): ChQuery => { + const { conditions, params } = windowFragment(window); + return { + query: ` + SELECT user_id, count() AS action_count + FROM user_activities + ${whereClause(conditions)} + GROUP BY user_id + ORDER BY action_count DESC + LIMIT {limit: UInt32} + `, + query_params: { ...params, limit }, + }; +}; + +export interface MostActiveUserRow { + userId: string; + actionCount: number; +} + +export const parseMostActiveUserRows = (rows: unknown[]): MostActiveUserRow[] => + (rows as Array<{ user_id: string; action_count: unknown }>).map((r) => ({ + userId: r.user_id, + actionCount: chCount(r.action_count), + })); + +export const buildFeatureUsageQuery = (window: TimeWindow): ChQuery => { + const { conditions, params } = windowFragment(window); + return { + query: ` + SELECT action, count() AS count + FROM user_activities + ${whereClause(conditions)} + GROUP BY action + ORDER BY count DESC + LIMIT {limit: UInt32} + `, + query_params: { ...params, limit: 15 }, + }; +}; + +export interface FeatureUsageRow { + action: string; + count: number; +} + +export const parseFeatureUsageRows = (rows: unknown[]): FeatureUsageRow[] => + (rows as Array<{ action: string; count: unknown }>).map((r) => ({ + action: r.action, + count: chCount(r.count), + })); + +export const buildActiveUserCountQuery = (since: Date): ChQuery => ({ + query: ` + SELECT uniqExact(user_id) AS count + FROM user_activities + WHERE timestamp >= {tw_start: DateTime64(3)} + `, + query_params: { tw_start: toClickHouseDateTime64(since) }, +}); + +export const parseActiveUserCount = (rows: unknown[]): number => + chCount((rows as Array<{ count: unknown }>)[0]?.count); diff --git a/packages/lib/src/observability/analytics-reads.ts b/packages/lib/src/observability/analytics-reads.ts new file mode 100644 index 0000000000..442f7834f5 --- /dev/null +++ b/packages/lib/src/observability/analytics-reads.ts @@ -0,0 +1,171 @@ +/** + * ClickHouse read shells for the analytics tier (#890 Phase 3 leaf 3). + * + * Thin I/O wrappers over the pure builders/parsers in + * analytics-read-core.ts — each function executes one built query via the + * provided client (JSONEachRow) and parses the rows to the PG-parity shape. + * SERVER-SIDE ONLY: the client carries CH credentials; these functions must + * never be reachable from a browser bundle (apps/web aliases + * '@clickhouse/client' to false for browser builds; admin readers are only + * imported from API routes). + * + * Unlike the insert adapters (never-throw, fire-and-forget), read errors + * PROPAGATE — a monitoring dashboard that cannot read its store must say so, + * not render empty panels. + */ +import type { ClickHouseClient } from '@clickhouse/client'; +import { + type ChQuery, + type TimeWindow, + buildActiveUserCountQuery, + buildActivityHeatmapQuery, + buildEndpointPerformanceQuery, + buildErrorPatternsQuery, + buildErrorTrendsQuery, + buildFailedLoginsQuery, + buildFeatureUsageQuery, + buildLogsByLevelQuery, + buildMostActiveUsersQuery, + buildRecentErrorsQuery, + buildRequestErrorCountsQuery, + buildResponseTimesQuery, + buildSlowQueriesQuery, + buildTopEndpointsQuery, + buildVolumeOverTimeQuery, + parseActiveUserCount, + parseActivityHeatmapRows, + parseEndpointPerformanceRows, + parseErrorPatternRows, + parseErrorTrendRows, + parseFailedLoginRows, + parseFeatureUsageRows, + parseLogsByLevelRows, + parseMostActiveUserRows, + parseRecentErrorRows, + parseRequestErrorCounts, + parseResponseTimeRows, + parseSlowQueryRows, + parseTopEndpointRows, + parseVolumeOverTimeRows, + type ActivityHeatmapRow, + type EndpointPerformanceRow, + type ErrorPatternRow, + type ErrorTrendRow, + type FailedLoginRow, + type FeatureUsageRow, + type LogsByLevelRow, + type MostActiveUserRow, + type RecentErrorRow, + type RequestErrorCounts, + type ResponseTimeRow, + type SlowQueryRow, + type TopEndpointRow, + type VolumeOverTimeRow, +} from './analytics-read-core'; + +export type { TimeWindow } from './analytics-read-core'; + +async function selectRows(client: ClickHouseClient, built: ChQuery): Promise { + const resultSet = await client.query({ + query: built.query, + query_params: built.query_params, + format: 'JSONEachRow', + }); + return resultSet.json(); +} + +// ── system_logs ────────────────────────────────────────────────────────────── + +export const getLogsByLevel = async ( + client: ClickHouseClient, + window: TimeWindow, +): Promise => parseLogsByLevelRows(await selectRows(client, buildLogsByLevelQuery(window))); + +export const getErrorTrends = async ( + client: ClickHouseClient, + window: TimeWindow, +): Promise => parseErrorTrendRows(await selectRows(client, buildErrorTrendsQuery(window))); + +export const getFailedLogins = async ( + client: ClickHouseClient, + window: TimeWindow, +): Promise => parseFailedLoginRows(await selectRows(client, buildFailedLoginsQuery(window))); + +// ── error_logs ─────────────────────────────────────────────────────────────── + +export const getRecentErrors = async ( + client: ClickHouseClient, + window: TimeWindow, + limit: number, +): Promise => + parseRecentErrorRows(await selectRows(client, buildRecentErrorsQuery(window, limit))); + +export const getErrorPatterns = async ( + client: ClickHouseClient, + window: TimeWindow, +): Promise => + parseErrorPatternRows(await selectRows(client, buildErrorPatternsQuery(window))); + +// ── api_metrics ────────────────────────────────────────────────────────────── + +export const getVolumeOverTime = async ( + client: ClickHouseClient, + window: TimeWindow, +): Promise => + parseVolumeOverTimeRows(await selectRows(client, buildVolumeOverTimeQuery(window))); + +export const getTopEndpoints = async ( + client: ClickHouseClient, + window: TimeWindow, +): Promise => + parseTopEndpointRows(await selectRows(client, buildTopEndpointsQuery(window))); + +export const getRequestErrorCounts = async ( + client: ClickHouseClient, + window: TimeWindow, +): Promise => + parseRequestErrorCounts(await selectRows(client, buildRequestErrorCountsQuery(window))); + +export const getResponseTimes = async ( + client: ClickHouseClient, + window: TimeWindow, +): Promise => + parseResponseTimeRows(await selectRows(client, buildResponseTimesQuery(window))); + +export const getSlowQueries = async ( + client: ClickHouseClient, + window: TimeWindow, +): Promise => + parseSlowQueryRows(await selectRows(client, buildSlowQueriesQuery(window))); + +export const getEndpointPerformance = async ( + client: ClickHouseClient, + window: TimeWindow, +): Promise => + parseEndpointPerformanceRows(await selectRows(client, buildEndpointPerformanceQuery(window))); + +// ── user_activities ────────────────────────────────────────────────────────── + +export const getActivityHeatmap = async ( + client: ClickHouseClient, + window: TimeWindow, +): Promise => + parseActivityHeatmapRows(await selectRows(client, buildActivityHeatmapQuery(window))); + +export const getMostActiveUsers = async ( + client: ClickHouseClient, + window: TimeWindow, + limit: number, +): Promise => + parseMostActiveUserRows(await selectRows(client, buildMostActiveUsersQuery(window, limit))); + +export const getFeatureUsage = async ( + client: ClickHouseClient, + window: TimeWindow, +): Promise => + parseFeatureUsageRows(await selectRows(client, buildFeatureUsageQuery(window))); + +export const getActiveUserCount = async ( + client: ClickHouseClient, + since: Date, +): Promise => parseActiveUserCount(await selectRows(client, buildActiveUserCountQuery(since))); diff --git a/packages/lib/src/observability/analytics-rows.ts b/packages/lib/src/observability/analytics-rows.ts new file mode 100644 index 0000000000..fc5ab44e41 --- /dev/null +++ b/packages/lib/src/observability/analytics-rows.ts @@ -0,0 +1,302 @@ +/** + * Pure row mapping for the ClickHouse analytics tier (#890 Phase 3 leaf 2). + * + * The write shapes mirror the main-PG writers in logging/logger-database.ts + * (camelCase, optional fields); the row shapes mirror the ClickHouse columns + * (snake_case, explicit null). Mapping is pure — id and timestamp are + * resolved by the caller and passed in, jsonb-shaped metadata serializes to a + * JSON string (CH String column), undefined becomes SQL NULL. + */ + +export const ANALYTICS_TABLES = { + apiMetrics: 'api_metrics', + systemLogs: 'system_logs', + userActivities: 'user_activities', + errorLogs: 'error_logs', +} as const; + +export type AnalyticsTable = (typeof ANALYTICS_TABLES)[keyof typeof ANALYTICS_TABLES]; + +/** id/timestamp resolved by the shell (caller-provided or generated). */ +export interface AssignedRowIdentity { + id: string; + timestamp: Date; +} + +/** + * UTC `YYYY-MM-DD HH:mm:ss.SSS` — the shape ClickHouse's basic date parser + * accepts for DateTime64(3, 'UTC') columns over JSONEachRow. + */ +export const toClickHouseDateTime64 = (date: Date): string => + date.toISOString().replace('T', ' ').replace('Z', ''); + +const orNull = (value: T | undefined): T | null => (value === undefined ? null : value); + +// JSON.stringify(undefined) is undefined, not a string — map that to NULL too. +// Circular metadata throws here; the adapter shell absorbs it (never-throw). +const metadataToJson = (metadata: unknown): string | null => { + if (metadata === undefined || metadata === null) return null; + const serialized = JSON.stringify(metadata); + return serialized === undefined ? null : serialized; +}; + +// ── api_metrics ────────────────────────────────────────────────────────────── + +export interface ApiMetricWrite { + id?: string; + timestamp?: Date; + endpoint: string; + method: string; + statusCode: number; + duration: number; + requestSize?: number; + responseSize?: number; + userId?: string; + sessionId?: string; + ip?: string; + userAgent?: string; + error?: string; + requestId?: string; + cacheHit?: boolean; + cacheKey?: string; +} + +export type ApiMetricRow = { + id: string; + timestamp: string; + endpoint: string; + method: string; + status_code: number; + duration: number; + request_size: number | null; + response_size: number | null; + user_id: string | null; + session_id: string | null; + ip: string | null; + user_agent: string | null; + error: string | null; + request_id: string | null; + cache_hit: boolean | null; + cache_key: string | null; +} + +export const mapApiMetricToRow = ( + input: ApiMetricWrite, + assigned: AssignedRowIdentity, +): ApiMetricRow => ({ + id: assigned.id, + timestamp: toClickHouseDateTime64(assigned.timestamp), + endpoint: input.endpoint, + method: input.method, + status_code: input.statusCode, + duration: input.duration, + request_size: orNull(input.requestSize), + response_size: orNull(input.responseSize), + user_id: orNull(input.userId), + session_id: orNull(input.sessionId), + ip: orNull(input.ip), + user_agent: orNull(input.userAgent), + error: orNull(input.error), + request_id: orNull(input.requestId), + cache_hit: orNull(input.cacheHit), + cache_key: orNull(input.cacheKey), +}); + +// ── system_logs ────────────────────────────────────────────────────────────── + +export interface SystemLogWrite { + id?: string; + timestamp?: Date; + level: string; + message: string; + category?: string; + userId?: string; + sessionId?: string; + requestId?: string; + driveId?: string; + pageId?: string; + endpoint?: string; + method?: string; + ip?: string; + userAgent?: string; + errorName?: string; + errorMessage?: string; + errorStack?: string; + duration?: number; + memoryUsed?: number; + memoryTotal?: number; + metadata?: unknown; + hostname?: string; + pid?: number; + version?: string; +} + +export type SystemLogRow = { + id: string; + timestamp: string; + level: string; + message: string; + /** ORDER BY key — non-nullable in CH, PG NULL maps to ''. */ + category: string; + user_id: string | null; + session_id: string | null; + request_id: string | null; + drive_id: string | null; + page_id: string | null; + endpoint: string | null; + method: string | null; + ip: string | null; + user_agent: string | null; + error_name: string | null; + error_message: string | null; + error_stack: string | null; + duration: number | null; + memory_used: number | null; + memory_total: number | null; + metadata: string | null; + hostname: string | null; + pid: number | null; + version: string | null; +} + +export const mapSystemLogToRow = ( + input: SystemLogWrite, + assigned: AssignedRowIdentity, +): SystemLogRow => ({ + id: assigned.id, + timestamp: toClickHouseDateTime64(assigned.timestamp), + level: input.level, + message: input.message, + category: input.category ?? '', + user_id: orNull(input.userId), + session_id: orNull(input.sessionId), + request_id: orNull(input.requestId), + drive_id: orNull(input.driveId), + page_id: orNull(input.pageId), + endpoint: orNull(input.endpoint), + method: orNull(input.method), + ip: orNull(input.ip), + user_agent: orNull(input.userAgent), + error_name: orNull(input.errorName), + error_message: orNull(input.errorMessage), + error_stack: orNull(input.errorStack), + duration: orNull(input.duration), + memory_used: orNull(input.memoryUsed), + memory_total: orNull(input.memoryTotal), + metadata: metadataToJson(input.metadata), + hostname: orNull(input.hostname), + pid: orNull(input.pid), + version: orNull(input.version), +}); + +// ── user_activities ────────────────────────────────────────────────────────── + +export interface UserActivityWrite { + id?: string; + timestamp?: Date; + userId: string; + action: string; + resource?: string; + resourceId?: string; + driveId?: string; + pageId?: string; + sessionId?: string; + ip?: string; + userAgent?: string; + metadata?: unknown; +} + +export type UserActivityRow = { + id: string; + timestamp: string; + user_id: string; + action: string; + session_id: string | null; + resource: string | null; + resource_id: string | null; + drive_id: string | null; + page_id: string | null; + metadata: string | null; + ip: string | null; + user_agent: string | null; +} + +export const mapUserActivityToRow = ( + input: UserActivityWrite, + assigned: AssignedRowIdentity, +): UserActivityRow => ({ + id: assigned.id, + timestamp: toClickHouseDateTime64(assigned.timestamp), + user_id: input.userId, + action: input.action, + session_id: orNull(input.sessionId), + resource: orNull(input.resource), + resource_id: orNull(input.resourceId), + drive_id: orNull(input.driveId), + page_id: orNull(input.pageId), + metadata: metadataToJson(input.metadata), + ip: orNull(input.ip), + user_agent: orNull(input.userAgent), +}); + +// ── error_logs ─────────────────────────────────────────────────────────────── + +export interface ErrorLogWrite { + id?: string; + timestamp?: Date; + name: string; + message: string; + stack?: string; + userId?: string; + sessionId?: string; + requestId?: string; + endpoint?: string; + method?: string; + file?: string; + line?: number; + column?: number; + ip?: string; + userAgent?: string; + metadata?: unknown; +} + +export type ErrorLogRow = { + id: string; + timestamp: string; + name: string; + message: string; + stack: string | null; + user_id: string | null; + session_id: string | null; + request_id: string | null; + endpoint: string | null; + method: string | null; + file: string | null; + line: number | null; + column: number | null; + ip: string | null; + user_agent: string | null; + metadata: string | null; +} + +export const mapErrorLogToRow = ( + input: ErrorLogWrite, + assigned: AssignedRowIdentity, +): ErrorLogRow => ({ + id: assigned.id, + timestamp: toClickHouseDateTime64(assigned.timestamp), + name: input.name, + message: input.message, + stack: orNull(input.stack), + user_id: orNull(input.userId), + session_id: orNull(input.sessionId), + request_id: orNull(input.requestId), + endpoint: orNull(input.endpoint), + method: orNull(input.method), + file: orNull(input.file), + line: orNull(input.line), + column: orNull(input.column), + ip: orNull(input.ip), + user_agent: orNull(input.userAgent), + metadata: metadataToJson(input.metadata), +}); diff --git a/packages/lib/src/observability/clickhouse-buffer.ts b/packages/lib/src/observability/clickhouse-buffer.ts new file mode 100644 index 0000000000..3137259d0e --- /dev/null +++ b/packages/lib/src/observability/clickhouse-buffer.ts @@ -0,0 +1,127 @@ +/** + * createClickHouseBuffer — pure buffered-insert factory for the ClickHouse + * analytics tier (#890 Phase 3). One instance per table, created at app + * startup; the insert adapters (Phase 3 leaf 2) stay pure (row) → void. + * + * Contract (design ref vzt8ufpdu6pvvorg6bxniswn): + * - No module-level state — everything lives in the factory closure. + * - Auto-flush at maxRows (500) OR flushIntervalMs (1000ms), whichever first. + * - insert()/flush() NEVER throw and never block the request path; a failed + * flush discards its batch (analytics rows are droppable, requests are not). + * - Flush errors log the table name + error message but NEVER row payloads (PII). + * - Concurrent insert() during an in-flight flush lands in a fresh batch + * (double-buffer swap) — it never blocks and is never swallowed. + * - drain() flushes remaining rows and awaits all in-flight flushes before + * resolving — the SIGTERM/SIGINT shutdown path. + */ + +export const DEFAULT_MAX_ROWS = 500; +export const DEFAULT_FLUSH_INTERVAL_MS = 1000; + +/** Thin seam over @clickhouse/client's insert — injected, never imported here. */ +export type ClickHouseInsertFn = (params: { + table: string; + values: Row[]; +}) => Promise; + +export interface ClickHouseBufferOpts { + insert: ClickHouseInsertFn; + maxRows?: number; + flushIntervalMs?: number; + /** Receives a payload-free message on flush failure. Defaults to console.error. */ + logError?: (message: string) => void; +} + +export interface ClickHouseBuffer { + /** Buffer a row; may trigger an async auto-flush. Never throws, never blocks. */ + insert: (row: Row) => void; + /** Flush pending rows now. Always resolves — flush failures are logged, not thrown. */ + flush: () => Promise; + /** Flush pending rows and await every in-flight flush (shutdown path). */ + drain: () => Promise; + /** Rows currently buffered (excludes batches already in flight). */ + pendingCount: () => number; +} + +export function createClickHouseBuffer( + table: string, + opts: ClickHouseBufferOpts, +): ClickHouseBuffer { + const maxRows = opts.maxRows ?? DEFAULT_MAX_ROWS; + const flushIntervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS; + const logError = opts.logError ?? ((message: string) => console.error(message)); + + let rows: Row[] = []; + let timer: NodeJS.Timeout | null = null; + const inFlight = new Set>(); + + const clearTimer = (): void => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; + + const flush = (): Promise => { + clearTimer(); + if (rows.length === 0) return Promise.resolve(); + + // Double-buffer swap: concurrent insert() calls land in the fresh array. + const batch = rows; + rows = []; + + // The batch is discarded on failure (never retried) and MUST NOT be + // logged — rows can carry PII. Table name + count + error message only. + const logFailure = (error: unknown): void => { + const message = error instanceof Error ? error.message : String(error); + logError( + `clickhouse-buffer: flush failed for table "${table}", dropping batch of ${batch.length}: ${message}`, + ); + }; + + // Invoke the client synchronously (the batch is handed off before flush() + // returns); a synchronously-throwing insert is absorbed the same as a + // rejected one. + let started: Promise; + try { + started = opts.insert({ table, values: batch }); + } catch (error) { + logFailure(error); + return Promise.resolve(); + } + + const attempt: Promise = started.catch(logFailure).finally(() => { + inFlight.delete(attempt); + }); + inFlight.add(attempt); + return attempt; + }; + + const scheduleFlush = (): void => { + if (timer !== null) return; + timer = setTimeout(() => { + timer = null; + void flush(); + }, flushIntervalMs); + // A pending analytics flush must not hold the process open; drain() is the + // deliberate shutdown path. Guarded: fake timers may lack unref. + timer.unref?.(); + }; + + return { + insert(row: Row): void { + rows.push(row); + if (rows.length >= maxRows) { + void flush(); + return; + } + scheduleFlush(); + }, + flush, + async drain(): Promise { + await flush(); + await Promise.all([...inFlight]); + }, + pendingCount: () => rows.length, + }; +} diff --git a/packages/lib/src/observability/clickhouse-client.ts b/packages/lib/src/observability/clickhouse-client.ts new file mode 100644 index 0000000000..c400fdf3be --- /dev/null +++ b/packages/lib/src/observability/clickhouse-client.ts @@ -0,0 +1,167 @@ +/** + * ClickHouse client shell for the analytics tier (#890 Phase 3). + * + * Composition root mirroring adminDb (packages/db/src/admin-db.ts): mode + * selection is pure (clickhouse-env.ts); this shell only constructs the + * @clickhouse/client instance and wires deps. Init is lazy — importing this + * module opens no connection and reads no env. + * + * Three-state contract at getClient(): + * disabled → null (default; zero behavior change, PG writes continue) + * enabled → client, constructed once per process + * misconfigured → throw ClickHouseMisconfiguredError (flag on but config + * missing/invalid — fail fast, never silently drop inserts) + * + * getGdprClient() answers a different question — where subject data COULD + * live, independent of the write-cutover flag — so a flag rollback never + * orphans CH rows from Art 15 export / Art 17 erasure: + * unconfigured → null (no CH env at all; rows can only be in main PG) + * configured → client, even when CLICKHOUSE_ENABLED is off + * misconfigured → throw (partial config — GDPR must not skip a store that + * may hold subject rows) + */ +import { createClient, type ClickHouseClient } from '@clickhouse/client'; +import { + ClickHouseMisconfiguredError, + isClickHouseEnabledFlag, + resolveClickHouseGdprMode, + resolveClickHouseMode, + type ClickHouseClientConfig, + type ClickHouseEnv, + type ClickHouseModeDecision, +} from './clickhouse-env'; + +export { + ClickHouseMisconfiguredError, + isClickHouseEnabledFlag, + resolveClickHouseGdprMode, + resolveClickHouseMode, + type ClickHouseClientConfig, + type ClickHouseEnv, + type ClickHouseGdprModeDecision, + type ClickHouseModeDecision, + type ClickHouseModeName, +} from './clickhouse-env'; +export type { ClickHouseClient } from '@clickhouse/client'; + +export interface ClickHouseRegistryDeps { + getEnv: () => ClickHouseEnv; + createClient: (config: ClickHouseClientConfig) => ClickHouseClient; +} + +export interface ClickHouseRegistry { + getClient: () => ClickHouseClient | null; + /** + * GDPR accessor: a client whenever connection config is resolvable (flag + * irrelevant), null only when no CH env exists at all, throw on partial + * config. Shares the per-process instance with getClient(). + */ + getGdprClient: () => ClickHouseClient | null; + /** Resolved-mode readback without init side effects — never constructs, never throws. */ + getMode: () => ClickHouseModeDecision; +} + +export function createClickHouseRegistry(deps: ClickHouseRegistryDeps): ClickHouseRegistry { + let instance: ClickHouseClient | null = null; + + // Both accessors resolve config from the same env vars, so whichever runs + // first constructs the single per-process instance. + const construct = (config: ClickHouseClientConfig): ClickHouseClient => { + if (!instance) instance = deps.createClient(config); + return instance; + }; + + return { + getClient(): ClickHouseClient | null { + if (instance) return instance; + const decision = resolveClickHouseMode(deps.getEnv()); + switch (decision.mode) { + case 'disabled': + return null; + case 'enabled': + return construct(decision.config); + case 'misconfigured': + throw new ClickHouseMisconfiguredError(`client init failed: ${decision.reason}`); + } + }, + getGdprClient(): ClickHouseClient | null { + const decision = resolveClickHouseGdprMode(deps.getEnv()); + switch (decision.mode) { + case 'unconfigured': + return null; + case 'configured': + return construct(decision.config); + case 'misconfigured': + throw new ClickHouseMisconfiguredError(`GDPR client unavailable: ${decision.reason}`); + } + }, + getMode() { + return resolveClickHouseMode(deps.getEnv()); + }, + }; +} + +const registry = createClickHouseRegistry({ + // Env is read at init time, not import time, so late-loaded dotenv wins. + getEnv: () => ({ + CLICKHOUSE_ENABLED: process.env.CLICKHOUSE_ENABLED, + CLICKHOUSE_URL: process.env.CLICKHOUSE_URL, + CLICKHOUSE_HOST: process.env.CLICKHOUSE_HOST, + CLICKHOUSE_USER: process.env.CLICKHOUSE_USER, + CLICKHOUSE_PASSWORD: process.env.CLICKHOUSE_PASSWORD, + CLICKHOUSE_DATABASE: process.env.CLICKHOUSE_DATABASE, + }), + createClient: (config) => createClient(config), +}); + +/** + * Per-process ClickHouse client accessor. Lazy: constructs (or returns null + * when the tier is off, or throws on misconfiguration) on first call, then + * returns the same instance for the process lifetime. + */ +export const getClickHouseClient = (): ClickHouseClient | null => registry.getClient(); + +/** + * GDPR client accessor: reaches CH wherever subject data COULD live — + * configured at all (even with the write flag off) → client; nothing set → + * null; partial config → throw. Export/erasure paths use this, never + * getClickHouseClient (#890 Phase 3 FIX, flag-rollback fail-open). + */ +export const getClickHouseGdprClient = (): ClickHouseClient | null => registry.getGdprClient(); + +/** Resolved analytics-tier mode for the current process env. Side-effect free. */ +export const getClickHouseMode = (): ClickHouseModeDecision => + registry.getMode(); + +/** True only when CLICKHOUSE_ENABLED is exactly 'true' (fail-closed). */ +export const isClickHouseEnabled = (): boolean => + isClickHouseEnabledFlag(process.env.CLICKHOUSE_ENABLED); + +/** + * True when the CH analytics store is (or could be) in play for subject data + * — any connection config or the flag present. Drives GDPR-critical + * decisions such as making the erasure purge step fatal. + */ +export const isClickHouseAnalyticsInPlay = (): boolean => + resolveClickHouseGdprMode({ + CLICKHOUSE_ENABLED: process.env.CLICKHOUSE_ENABLED, + CLICKHOUSE_URL: process.env.CLICKHOUSE_URL, + CLICKHOUSE_HOST: process.env.CLICKHOUSE_HOST, + CLICKHOUSE_USER: process.env.CLICKHOUSE_USER, + CLICKHOUSE_PASSWORD: process.env.CLICKHOUSE_PASSWORD, + CLICKHOUSE_DATABASE: process.env.CLICKHOUSE_DATABASE, + }).mode !== 'unconfigured'; + +/** + * Startup probe for composition roots (web/admin instrumentation, processor + * server): a half-configured deploy (flag on, config missing) must CRASH the + * process at boot, not silently black out telemetry while enqueue() absorbs + * per-row errors (#890 Phase 3 FIX). Returns the decision for startup logs. + */ +export const probeClickHouseStartup = (): ClickHouseModeDecision => { + const decision = registry.getMode(); + if (decision.mode === 'misconfigured') { + throw new ClickHouseMisconfiguredError(`startup probe failed: ${decision.reason}`); + } + return decision; +}; diff --git a/packages/lib/src/observability/clickhouse-ddl.ts b/packages/lib/src/observability/clickhouse-ddl.ts new file mode 100644 index 0000000000..976864750a --- /dev/null +++ b/packages/lib/src/observability/clickhouse-ddl.ts @@ -0,0 +1,157 @@ +/** + * MergeTree DDL for the ClickHouse analytics tier (#890 Phase 3 leaf 2). + * Design ref lkjmhbmjekgbntk2z4rsjzw2 is authoritative for ORDER BY, + * LowCardinality, and TTL choices. + * + * Migration mechanism: an explicit, idempotent apply (CREATE TABLE IF NOT + * EXISTS) driven by scripts/clickhouse-migrate.ts — dev container and prod + * run the same script. Deliberately NOT a startup bootstrap: the app runtime + * credential then never needs DDL grants (zero-trust: app = INSERT-only, + * mirroring the Admin PG role split), and a slow/unreachable CH can't delay + * or fail app boot. + * + * Column shapes mirror packages/db/src/schema/monitoring.ts: + * text → String / Nullable(String) · integer → Int32 · boolean → Bool + * jsonb → String (JSON-serialized) · timestamp → DateTime64(3, 'UTC') + * Two deliberate divergences: system_logs.category is an ORDER BY key so PG + * NULL maps to '' (sorting keys cannot be Nullable), and error_logs drops the + * mutable resolution workflow columns (resolved/resolvedAt/resolvedBy/ + * resolution) — CH rows are immutable; the resolved-flag workflow moves to an + * error_resolutions mini-table in main PG (leaf 3). + */ + +import type { AnalyticsTable } from './analytics-rows'; + +export interface AnalyticsTableDdl { + table: AnalyticsTable; + ddl: string; +} + +export const ANALYTICS_TABLE_DDL: readonly AnalyticsTableDdl[] = [ + { + table: 'api_metrics', + ddl: ` + CREATE TABLE IF NOT EXISTS api_metrics ( + \`id\` String, + \`timestamp\` DateTime64(3, 'UTC'), + \`endpoint\` LowCardinality(String), + \`method\` LowCardinality(String), + \`status_code\` Int32, + \`duration\` Int32, + \`request_size\` Nullable(Int32), + \`response_size\` Nullable(Int32), + \`user_id\` Nullable(String), + \`session_id\` Nullable(String), + \`ip\` Nullable(String), + \`user_agent\` Nullable(String), + \`error\` Nullable(String), + \`request_id\` Nullable(String), + \`cache_hit\` Nullable(Bool), + \`cache_key\` Nullable(String) + ) + ENGINE = MergeTree + PARTITION BY toYYYYMM(timestamp) + ORDER BY (timestamp, endpoint, status_code) + TTL toDateTime(timestamp) + INTERVAL 90 DAY + `, + }, + { + table: 'system_logs', + ddl: ` + CREATE TABLE IF NOT EXISTS system_logs ( + \`id\` String, + \`timestamp\` DateTime64(3, 'UTC'), + \`level\` LowCardinality(String), + \`message\` String, + \`category\` LowCardinality(String) DEFAULT '', + \`user_id\` Nullable(String), + \`session_id\` Nullable(String), + \`request_id\` Nullable(String), + \`drive_id\` Nullable(String), + \`page_id\` Nullable(String), + \`endpoint\` Nullable(String), + \`method\` LowCardinality(Nullable(String)), + \`ip\` Nullable(String), + \`user_agent\` Nullable(String), + \`error_name\` Nullable(String), + \`error_message\` Nullable(String), + \`error_stack\` Nullable(String), + \`duration\` Nullable(Int32), + \`memory_used\` Nullable(Int32), + \`memory_total\` Nullable(Int32), + \`metadata\` Nullable(String), + \`hostname\` Nullable(String), + \`pid\` Nullable(Int32), + \`version\` Nullable(String) + ) + ENGINE = MergeTree + PARTITION BY toYYYYMM(timestamp) + ORDER BY (timestamp, level, category) + TTL toDateTime(timestamp) + INTERVAL 30 DAY + `, + }, + { + table: 'user_activities', + ddl: ` + CREATE TABLE IF NOT EXISTS user_activities ( + \`id\` String, + \`timestamp\` DateTime64(3, 'UTC'), + \`user_id\` String, + \`action\` LowCardinality(String), + \`session_id\` Nullable(String), + \`resource\` LowCardinality(Nullable(String)), + \`resource_id\` Nullable(String), + \`drive_id\` Nullable(String), + \`page_id\` Nullable(String), + \`metadata\` Nullable(String), + \`ip\` Nullable(String), + \`user_agent\` Nullable(String) + ) + ENGINE = MergeTree + PARTITION BY toYYYYMM(timestamp) + ORDER BY (user_id, timestamp, action) + TTL toDateTime(timestamp) + INTERVAL 180 DAY + `, + }, + { + table: 'error_logs', + ddl: ` + CREATE TABLE IF NOT EXISTS error_logs ( + \`id\` String, + \`timestamp\` DateTime64(3, 'UTC'), + \`name\` LowCardinality(String), + \`message\` String, + \`stack\` Nullable(String), + \`user_id\` Nullable(String), + \`session_id\` Nullable(String), + \`request_id\` Nullable(String), + \`endpoint\` Nullable(String), + \`method\` LowCardinality(Nullable(String)), + \`file\` Nullable(String), + \`line\` Nullable(Int32), + \`column\` Nullable(Int32), + \`ip\` Nullable(String), + \`user_agent\` Nullable(String), + \`metadata\` Nullable(String) + ) + ENGINE = MergeTree + PARTITION BY toYYYYMM(timestamp) + ORDER BY (timestamp, name) + `, + }, +]; + +/** Structural slice of ClickHouseClient — DDL needs only command(). */ +export interface ClickHouseCommandRunner { + command: (params: { query: string }) => Promise; +} + +/** + * Apply the analytics DDL. Idempotent (IF NOT EXISTS); errors PROPAGATE — + * a migration that cannot apply must fail loudly, unlike the insert path. + */ +export async function ensureAnalyticsTables(client: ClickHouseCommandRunner): Promise { + for (const { ddl } of ANALYTICS_TABLE_DDL) { + await client.command({ query: ddl }); + } +} diff --git a/packages/lib/src/observability/clickhouse-env.ts b/packages/lib/src/observability/clickhouse-env.ts new file mode 100644 index 0000000000..85460b6369 --- /dev/null +++ b/packages/lib/src/observability/clickhouse-env.ts @@ -0,0 +1,192 @@ +/** + * Pure decision logic for the ClickHouse analytics tier (#890 Phase 3). + * No I/O, no process.env reads — callers pass env values in. Mirrors the + * Admin PG three-state contract (packages/db/src/admin-db-mode.ts): + * + * CLICKHOUSE_ENABLED not exactly 'true' → 'disabled' (default — + * the 4 analytics tables keep writing to the main PG unchanged) + * flag on + connection config resolvable → 'enabled' + * flag on + config missing/invalid → 'misconfigured' (the + * client shell throws at init — never silently drop inserts) + * + * Credentials are server-side secrets: they never reach the browser and + * .env.example carries placeholders only. + */ + +export interface ClickHouseEnv { + CLICKHOUSE_ENABLED?: string | undefined; + CLICKHOUSE_URL?: string | undefined; + CLICKHOUSE_HOST?: string | undefined; + CLICKHOUSE_USER?: string | undefined; + CLICKHOUSE_PASSWORD?: string | undefined; + CLICKHOUSE_DATABASE?: string | undefined; +} + +export type ClickHouseModeName = 'disabled' | 'enabled' | 'misconfigured'; + +/** Connection config consumed by @clickhouse/client's createClient. */ +export interface ClickHouseClientConfig { + url: string; + username: string; + password: string; + database: string; +} + +export type ClickHouseModeDecision = + | { mode: 'disabled'; reason: string } + | { mode: 'enabled'; reason: string; config: ClickHouseClientConfig } + | { mode: 'misconfigured'; reason: string }; + +/** + * Typed misconfiguration failure, so callers (the insert adapters, startup + * probes) can distinguish "this deploy is half-configured" from a transient + * flush/network failure. Never absorbed as routine noise. + */ +export class ClickHouseMisconfiguredError extends Error { + constructor(reason: string) { + super(`ClickHouse misconfigured: ${reason}`); + this.name = 'ClickHouseMisconfiguredError'; + } +} + +/** + * GDPR availability: where subject analytics rows COULD live, independent of + * the write-cutover flag. Export/erasure must reach CH whenever connection + * config exists — a flag rollback after rows landed in CH must not orphan + * them from Art 15/17 (#890 Phase 3 FIX). + */ +export type ClickHouseGdprModeDecision = + | { mode: 'unconfigured'; reason: string } + | { mode: 'configured'; reason: string; config: ClickHouseClientConfig } + | { mode: 'misconfigured'; reason: string }; + +// Fail-closed: only the exact string 'true' enables. 'TRUE', '1', ' true ', +// '' etc. do not — mirrors CODE_EXECUTION_ENABLED and ADMIN_DB_BREAK_GLASS. +export const isClickHouseEnabledFlag = (flag: string | undefined): boolean => + flag === 'true'; + +const isHttpUrl = (value: string): boolean => + value.startsWith('http://') || value.startsWith('https://'); + +const hasScheme = (value: string): boolean => value.includes('://'); + +// ClickHouse Cloud serves HTTPS on 8443; a bare hostname resolves there. +// Anything with an explicit scheme must be http(s) — the JS client speaks +// the HTTP interface only (never the native 9000 port). +const resolveUrl = (host: string): { url: string } | { error: string } => { + if (!hasScheme(host)) return { url: `https://${host}:8443` }; + if (isHttpUrl(host)) return { url: host }; + return { + error: `must be an http:// or https:// URL or a bare hostname (got a non-http scheme)`, + }; +}; + +const isSet = (value: string | undefined): value is string => + value !== undefined && value !== ''; + +type ConnectionConfigResolution = + | { ok: true; config: ClickHouseClientConfig; sourceName: 'CLICKHOUSE_URL' | 'CLICKHOUSE_HOST' } + | { ok: false; reason: string }; + +const resolveConnectionConfig = (env: ClickHouseEnv): ConnectionConfigResolution => { + const username = env.CLICKHOUSE_USER; + const password = env.CLICKHOUSE_PASSWORD; + const database = env.CLICKHOUSE_DATABASE; + const source = isSet(env.CLICKHOUSE_URL) + ? { name: 'CLICKHOUSE_URL' as const, value: env.CLICKHOUSE_URL } + : isSet(env.CLICKHOUSE_HOST) + ? { name: 'CLICKHOUSE_HOST' as const, value: env.CLICKHOUSE_HOST } + : undefined; + + // Report everything missing in one pass so a misconfigured deploy is fixed + // in one iteration, not one env var at a time. + if (!source || !isSet(username) || !isSet(password) || !isSet(database)) { + const missing = [ + !source && 'CLICKHOUSE_HOST (or CLICKHOUSE_URL)', + !isSet(username) && 'CLICKHOUSE_USER', + !isSet(password) && 'CLICKHOUSE_PASSWORD', + !isSet(database) && 'CLICKHOUSE_DATABASE', + ].filter((name): name is string => typeof name === 'string'); + return { + ok: false, + reason: `${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} not set.`, + }; + } + + const resolved = + source.name === 'CLICKHOUSE_URL' + ? isHttpUrl(source.value) + ? { url: source.value } + : { error: 'must be an http:// or https:// URL' } + : resolveUrl(source.value); + if ('error' in resolved) { + return { ok: false, reason: `${source.name} ${resolved.error}.` }; + } + + return { + ok: true, + sourceName: source.name, + config: { url: resolved.url, username, password, database }, + }; +}; + +export const resolveClickHouseMode = (env: ClickHouseEnv): ClickHouseModeDecision => { + if (!isClickHouseEnabledFlag(env.CLICKHOUSE_ENABLED)) { + return { + mode: 'disabled', + reason: + "CLICKHOUSE_ENABLED is not 'true' — the analytics tier is off; the 4 analytics tables keep writing to the main PG", + }; + } + + const resolved = resolveConnectionConfig(env); + if (!resolved.ok) { + return { + mode: 'misconfigured', + reason: `CLICKHOUSE_ENABLED='true' but ${resolved.reason} Set the missing config or turn the flag off.`, + }; + } + + return { + mode: 'enabled', + reason: `CLICKHOUSE_ENABLED='true' with connection config from ${resolved.sourceName}`, + config: resolved.config, + }; +}; + +export const resolveClickHouseGdprMode = (env: ClickHouseEnv): ClickHouseGdprModeDecision => { + const anyConnectionVarSet = [ + env.CLICKHOUSE_URL, + env.CLICKHOUSE_HOST, + env.CLICKHOUSE_USER, + env.CLICKHOUSE_PASSWORD, + env.CLICKHOUSE_DATABASE, + ].some(isSet); + const flagOn = isClickHouseEnabledFlag(env.CLICKHOUSE_ENABLED); + + if (!anyConnectionVarSet && !flagOn) { + return { + mode: 'unconfigured', + reason: + 'no ClickHouse connection config is set — subject analytics rows can only live in the main PG', + }; + } + + // Any CH env at all means CH is presumed in play; failing to build a client + // from it is misconfiguration, never a silent skip (fail-closed for GDPR). + const resolved = resolveConnectionConfig(env); + if (!resolved.ok) { + return { + mode: 'misconfigured', + reason: `ClickHouse is partially configured but ${resolved.reason} GDPR export/erasure cannot skip a store that may hold subject rows.`, + }; + } + + return { + mode: 'configured', + reason: flagOn + ? `connection config from ${resolved.sourceName}` + : `connection config from ${resolved.sourceName} (CLICKHOUSE_ENABLED is off — rollback window; CH may still hold subject rows)`, + config: resolved.config, + }; +}; diff --git a/packages/lib/src/observability/error-resolutions.ts b/packages/lib/src/observability/error-resolutions.ts new file mode 100644 index 0000000000..8add5a35ef --- /dev/null +++ b/packages/lib/src/observability/error-resolutions.ts @@ -0,0 +1,169 @@ +/** + * Error resolution workflow (#890 Phase 3 leaf 3). + * + * Post-cutover, error_logs rows live in immutable ClickHouse — a mutable + * resolved flag cannot live on them. The workflow state (resolved, + * resolvedBy, resolvedAt, notes) lives in the error_resolutions mini-table + * in MAIN PG, keyed by the error row's stable id (the same id space whether + * the row came from CH or the legacy PG table). + * + * The two stores are never SQL-joined. Reads are two-step, merged in app + * code: fetch error rows from their store → fetch resolutions by id from PG + * → mergeErrorResolutions. Writes (the resolve action) target ONLY PG. + * This is the same cross-store join pattern Phase 5 will use for + * activity_logs. + */ +import { db } from '@pagespace/db/db'; +import { errorLogs, errorResolutions } from '@pagespace/db/schema/monitoring'; +import { and, desc, gte, inArray, lte } from '@pagespace/db/operators'; +import type { SQL } from '@pagespace/db/operators'; +import { getClickHouseClient, isClickHouseEnabled } from './clickhouse-client'; +import { getRecentErrors } from './analytics-reads'; +import type { RecentErrorRow, TimeWindow } from './analytics-read-core'; + +export interface ErrorResolutionRecord { + errorId: string; + resolved: boolean; + resolvedAt: Date; + resolvedBy: string | null; + resolution: string | null; +} + +export interface ErrorResolutionInput { + errorId: string; + resolved?: boolean; + resolvedBy?: string | null; + resolution?: string | null; +} + +export type ResolutionFields = { + resolved: boolean; + resolvedAt: Date | null; + resolvedBy: string | null; + resolution: string | null; +}; + +// ── Pure core ──────────────────────────────────────────────────────────────── + +/** Fill defaults for an upsert: resolving is the default action, reopen is explicit. */ +export const normalizeResolutionInput = ( + input: ErrorResolutionInput, + now: Date, +): ErrorResolutionRecord => ({ + errorId: input.errorId, + resolved: input.resolved ?? true, + resolvedAt: now, + resolvedBy: input.resolvedBy ?? null, + resolution: input.resolution ?? null, +}); + +/** + * Attach resolution state to error rows by id. Errors without a resolution + * row are unresolved; resolution rows matching no error are dropped. + */ +export const mergeErrorResolutions = ( + errors: readonly E[], + resolutions: readonly ErrorResolutionRecord[], +): Array => { + const byId = new Map(resolutions.map((r) => [r.errorId, r])); + return errors.map((error) => { + const match = byId.get(error.id); + return { + ...error, + resolved: match?.resolved ?? false, + resolvedAt: match?.resolvedAt ?? null, + resolvedBy: match?.resolvedBy ?? null, + resolution: match?.resolution ?? null, + }; + }); +}; + +export interface ErrorResolutionReaderDeps { + fetchErrors: (window: TimeWindow, limit: number) => Promise; + fetchResolutions: (errorIds: string[]) => Promise; +} + +/** Two-step cross-store reader: errors from their store, resolutions from PG, merged here. */ +export const createErrorResolutionReader = + (deps: ErrorResolutionReaderDeps) => + async (window: TimeWindow, limit: number): Promise> => { + const errors = await deps.fetchErrors(window, limit); + if (errors.length === 0) return []; + const resolutions = await deps.fetchResolutions(errors.map((e) => e.id)); + return mergeErrorResolutions(errors, resolutions); + }; + +// ── PG shells ──────────────────────────────────────────────────────────────── + +/** The resolve action: upsert the workflow row in main PG (never touches CH). */ +export async function setErrorResolution(input: ErrorResolutionInput): Promise { + const record = normalizeResolutionInput(input, new Date()); + await db + .insert(errorResolutions) + .values(record) + .onConflictDoUpdate({ + target: errorResolutions.errorId, + set: { + resolved: record.resolved, + resolvedAt: record.resolvedAt, + resolvedBy: record.resolvedBy, + resolution: record.resolution, + }, + }); + return record; +} + +export async function fetchErrorResolutions(errorIds: string[]): Promise { + if (errorIds.length === 0) return []; + return db + .select({ + errorId: errorResolutions.errorId, + resolved: errorResolutions.resolved, + resolvedAt: errorResolutions.resolvedAt, + resolvedBy: errorResolutions.resolvedBy, + resolution: errorResolutions.resolution, + }) + .from(errorResolutions) + .where(inArray(errorResolutions.errorId, errorIds)); +} + +/** Error rows from whichever store is live: CH when enabled, else main PG. */ +export async function fetchRecentErrors( + window: TimeWindow, + limit: number, +): Promise { + if (isClickHouseEnabled()) { + const client = getClickHouseClient(); + if (client) return getRecentErrors(client, window, limit); + } + const conditions: SQL[] = []; + if (window.startDate) conditions.push(gte(errorLogs.timestamp, window.startDate)); + if (window.endDate) conditions.push(lte(errorLogs.timestamp, window.endDate)); + return db + .select({ + id: errorLogs.id, + timestamp: errorLogs.timestamp, + message: errorLogs.message, + errorName: errorLogs.name, + errorMessage: errorLogs.stack, + endpoint: errorLogs.endpoint, + userId: errorLogs.userId, + }) + .from(errorLogs) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(errorLogs.timestamp)) + .limit(limit); +} + +/** + * The error-analytics read path with resolution state attached — the + * canonical replacement for reading error_logs.resolved* columns directly. + */ +export const listRecentErrorsWithResolutions = ( + window: TimeWindow, + limit = 20, +): Promise> => + createErrorResolutionReader({ + fetchErrors: fetchRecentErrors, + fetchResolutions: fetchErrorResolutions, + })(window, limit); diff --git a/scripts/clickhouse-migrate.ts b/scripts/clickhouse-migrate.ts new file mode 100644 index 0000000000..db6897b804 --- /dev/null +++ b/scripts/clickhouse-migrate.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env bun +/** + * ClickHouse analytics-tier migration (#890 Phase 3 leaf 2). + * + * Applies the MergeTree DDL for the 4 analytics tables (api_metrics, + * system_logs, user_activities, error_logs) — idempotent CREATE TABLE IF NOT + * EXISTS, safe to re-run anytime. Dev container and prod (ClickHouse Cloud) + * run this same script; it is deliberately NOT an app-startup bootstrap so + * the app runtime credential never needs DDL grants and a slow CH can't + * delay app boot. The target DATABASE must already exist (the dev container + * creates it from CLICKHOUSE_DB; ClickHouse Cloud from its console). + * + * Usage (env-driven, same vars as the app): + * CLICKHOUSE_ENABLED=true CLICKHOUSE_URL=http://localhost:8123 \ + * CLICKHOUSE_USER=user CLICKHOUSE_PASSWORD=password \ + * CLICKHOUSE_DATABASE=pagespace_analytics \ + * bun run scripts/clickhouse-migrate.ts + */ + +import { + getClickHouseClient, + getClickHouseMode, +} from '@pagespace/lib/observability/clickhouse-client'; +import { + ANALYTICS_TABLE_DDL, + ensureAnalyticsTables, +} from '@pagespace/lib/observability/clickhouse-ddl'; + +async function main(): Promise { + const decision = getClickHouseMode(); + if (decision.mode !== 'enabled') { + console.error(`[clickhouse-migrate] cannot run: ${decision.reason}`); + console.error( + '[clickhouse-migrate] set CLICKHOUSE_ENABLED=true plus CLICKHOUSE_URL (or CLICKHOUSE_HOST), CLICKHOUSE_USER, CLICKHOUSE_PASSWORD, CLICKHOUSE_DATABASE in the environment for this run.', + ); + process.exit(1); + } + + const client = getClickHouseClient(); + if (!client) { + // Unreachable given the mode check above, but fail loudly rather than NPE. + console.error('[clickhouse-migrate] client unexpectedly null despite enabled mode'); + process.exit(1); + } + + console.log(`[clickhouse-migrate] target: ${decision.config.url} db=${decision.config.database}`); + await ensureAnalyticsTables(client); + for (const { table } of ANALYTICS_TABLE_DDL) { + console.log(`[clickhouse-migrate] ensured table ${table}`); + } + await client.close(); + console.log('[clickhouse-migrate] done (idempotent — safe to re-run)'); +} + +main().catch((error) => { + console.error('[clickhouse-migrate] failed:', error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/scripts/deferred-migrations/0890-phase6-drop-analytics-pg-tables.sql b/scripts/deferred-migrations/0890-phase6-drop-analytics-pg-tables.sql new file mode 100644 index 0000000000..8a205442e9 --- /dev/null +++ b/scripts/deferred-migrations/0890-phase6-drop-analytics-pg-tables.sql @@ -0,0 +1,60 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- DEFERRED — DO NOT RUN BEFORE #890 PHASE 6 (task kws85p45pvjnivoz3uxs83yp) +-- ════════════════════════════════════════════════════════════════════════════ +-- +-- Drops the 4 analytics tables from MAIN PG after the ClickHouse cutover +-- (#890 Phase 3). Authored by Phase 3 leaf 4; execution deliberately deferred: +-- there is NO backfill of pre-cutover analytics rows into ClickHouse, so +-- running this before the Phase 6 history decision (task f70ay5fo488veub4fak4feqs +-- — backfill vs age-out) destroys the only copy of [start→cutover] metrics, +-- logs, activities, and errors. +-- +-- PRECONDITIONS (all must hold before removing the guard below): +-- 1. CLICKHOUSE_ENABLED=true in the target environment, soaked, with the +-- monitoring UIs reading CH (Phase 3 leaf 3) and inserts flowing +-- (Phase 3 leaf 2). scripts/clickhouse-migrate.ts already applied. +-- 2. The pre-cutover-history decision task f70ay5fo488veub4fak4feqs is +-- resolved (history backfilled into CH, or explicitly aged out / +-- accepted as lost). +-- 3. GDPR erasure/export against CH is live (Phase 3 leaf 4: +-- analytics-gdpr.ts) — after the drop it is the ONLY copy. +-- +-- CANONICAL EXECUTION PATH (preferred over psql -f of this file): +-- delete the four pgTable definitions + their relations from +-- packages/db/src/schema/monitoring.ts (KEEP errorResolutions, aiUsageLogs, +-- activityLogs), run `bun run db:generate`, and ship the generated +-- migration — this file is the reviewed reference for what that migration +-- must contain, and the break-glass manual fallback. +-- In the same change, delete the now-dead off-mode PG code paths: +-- apps/admin + apps/web monitoring-queries PG branches, the PG legs in +-- gdpr-export.ts / monitoring-purge.ts / monitoring-retention.ts, and the +-- errorLogs fallback in observability/error-resolutions.ts +-- (fetchRecentErrors). +-- +-- What stays in main PG, permanently: +-- error_resolutions (mutable resolved-flag workflow — CH rows are immutable), +-- ai_usage_logs (billing joins + cost-reconcile UPDATEs; Phase 4 CDC), +-- activity_logs (hash-chained; Phase 5 owns its move). +-- Admin PG chain tables (security_audit_log, siem_delivery_receipts) are +-- untouchable by design: infinite retention, create-ahead-only partitions, +-- no drop path exists (Art 17(3)(b); drizzle-admin/0002). + +-- Guard: makes an accidental execution fail loudly. The DROPs live INSIDE +-- this single DO block, after the RAISE — deliberately. psql's default +-- ON_ERROR_STOP=off reports a failed statement then CONTINUES, so a guard +-- that is its own statement would not stop `psql -f` from executing DROPs +-- that follow it. One statement = the RAISE genuinely aborts the drops. +-- Remove ONLY the RAISE line as part of the Phase 6 task above, with the +-- preconditions checked off. +DO $$ +BEGIN + RAISE EXCEPTION '#890: deferred to Phase 6 task kws85p45pvjnivoz3uxs83yp — pre-cutover analytics history is not backfilled to ClickHouse; see header before removing this guard'; + + -- Indexes, sequences, and FK constraints owned by these tables drop with + -- them. No inbound FKs exist (telemetry tables; verified at authoring time). + DROP TABLE IF EXISTS "api_metrics"; + DROP TABLE IF EXISTS "system_logs"; + DROP TABLE IF EXISTS "user_activities"; + DROP TABLE IF EXISTS "error_logs"; +END +$$;