diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index 16bf1db02f..c4a241f8a1 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -172,6 +172,41 @@ const eslintConfig = [ ], }, }, + { + // AI stream surfaces: a stale closure here is not a re-render bug, it is a RUNAWAY AGENT. + // + // Streams are server-owned and deliberately survive a client disconnect, so the only thing + // that stops a generation is an explicit abort naming it correctly. Every stale value in + // these files — a captured conversationId, a captured messageId, a captured isStreaming — + // means Stop names the wrong stream (or nothing), the fetch is cancelled, the button flips + // back to Send, and the server keeps generating, keeps running write tools, and KEEPS + // BILLING while the user believes it stopped. That has now happened repeatedly. + // + // `react-hooks/exhaustive-deps` catches exactly this, and it was already enabled — as a + // WARNING, via next/core-web-vitals. `bun run lint` exits 0 on warnings, so CI reported + // "14/14 successful" while the rule was pointing straight at a missing dep in handleStop. + // The signal existed and was wired to nothing. + // + // Scoped to the files that own stream identity, and verified to be at ZERO violations and + // ZERO suppressions when added — so this costs nothing today and fails CI on the next one. + // If it fires, do not silence it: a dep you are tempted to omit here is a stop button you + // are about to break. + files: [ + "src/hooks/useChannelStreamSocket.ts", + "src/hooks/useAgentChannelMultiplayer.ts", + "src/hooks/useAppStateRecovery.ts", + "src/contexts/GlobalChatContext.tsx", + "src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx", + "src/components/layout/middle-content/page-views/dashboard/useGlobalEffectiveStream.ts", + "src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx", + "src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx", + "src/lib/ai/shared/hooks/useChatStop.ts", + "src/lib/ai/shared/hooks/useChatTransport.ts", + ], + rules: { + "react-hooks/exhaustive-deps": "error", + }, + }, ]; export default eslintConfig; diff --git a/apps/web/src/app/api/ai/abort/__tests__/route.test.ts b/apps/web/src/app/api/ai/abort/__tests__/route.test.ts index 915091e7c0..a2b93eef7c 100644 --- a/apps/web/src/app/api/ai/abort/__tests__/route.test.ts +++ b/apps/web/src/app/api/ai/abort/__tests__/route.test.ts @@ -6,6 +6,10 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const mockAbortConversationStreams = vi.hoisted(() => + vi.fn().mockResolvedValue({ aborted: [], reason: 'No in-flight stream on this conversation' }), +); import { NextResponse } from 'next/server'; import { POST } from '../route'; import type { SessionAuthResult, AuthError } from '@/lib/auth'; @@ -17,6 +21,10 @@ vi.mock('@/lib/auth', () => ({ })); // Mock stream abort registry (boundary) +vi.mock('@/lib/ai/core/abort-conversation-streams', () => ({ + abortConversationStreams: mockAbortConversationStreams, +})); + vi.mock('@/lib/ai/core/stream-abort-registry', () => ({ abortStream: vi.fn(), abortStreamByMessageId: vi.fn(), @@ -96,6 +104,59 @@ describe('POST /api/ai/abort', () => { }); }); + // THE WINDOW STOP COULD NOT NAME. + // + // streamId and messageId are BOTH minted server-side, and the client learns neither until the + // response headers land. A real agent send spends 0.5-3s before that (auth, rate limit, DB + // reads, context assembly, provider connect). Press Stop in that window — exactly when a user + // who spotted a typo presses it — and the client had nothing to name: the abort was a + // guaranteed no-op, the fetch was cancelled, and the button flipped back to Send. + // + // Cancelling the fetch stops NOTHING: streams are deliberately server-owned and survive a + // client disconnect. The generation kept running, kept calling write tools, and kept BILLING, + // while the UI said it had stopped. conversationId is the one name the client holds from t=0. + describe('abort by conversationId (the pre-headers window)', () => { + it('accepts conversationId alone and aborts the conversation\'s in-flight stream', async () => { + vi.mocked(authenticateRequestWithOptions).mockResolvedValueOnce(mockWebAuth(mockUserId)); + vi.mocked(isAuthError).mockReturnValueOnce(false); + mockAbortConversationStreams.mockResolvedValueOnce({ aborted: ['msg-1'], reason: '' }); + + const response = await POST(createRequest({ conversationId: 'conv-1' })); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(mockAbortConversationStreams).toHaveBeenCalledWith({ + conversationId: 'conv-1', + userId: expect.any(String), + }); + expect(body.aborted).toBe(true); + }); + + it('given nothing was in flight, reports honestly rather than claiming success', async () => { + vi.mocked(authenticateRequestWithOptions).mockResolvedValueOnce(mockWebAuth(mockUserId)); + vi.mocked(isAuthError).mockReturnValueOnce(false); + mockAbortConversationStreams.mockResolvedValueOnce({ + aborted: [], + reason: 'No in-flight stream on this conversation', + }); + + const body = await (await POST(createRequest({ conversationId: 'conv-1' }))).json(); + + expect(body.aborted).toBe(false); + }); + + // messageId names the stream exactly; conversationId is only the fallback for when nothing + // else exists yet. Preferring the fallback would abort every stream on the conversation. + it('prefers messageId when both are supplied', async () => { + vi.mocked(authenticateRequestWithOptions).mockResolvedValueOnce(mockWebAuth(mockUserId)); + vi.mocked(isAuthError).mockReturnValueOnce(false); + + await POST(createRequest({ messageId: 'msg-precise', conversationId: 'conv-1' })); + + expect(mockAbortConversationStreams).not.toHaveBeenCalled(); + }); + }); + describe('Input Validation', () => { it('returns 400 when neither streamId nor messageId provided', async () => { vi.mocked(authenticateRequestWithOptions).mockResolvedValueOnce(mockWebAuth(mockUserId)); @@ -106,7 +167,7 @@ describe('POST /api/ai/abort', () => { const data = await response.json(); expect(response.status).toBe(400); - expect(data.error).toBe('streamId or messageId is required'); + expect(data.error).toBe('streamId, messageId or conversationId is required'); expect(abortStream).not.toHaveBeenCalled(); expect(abortStreamByMessageId).not.toHaveBeenCalled(); }); @@ -120,7 +181,7 @@ describe('POST /api/ai/abort', () => { const data = await response.json(); expect(response.status).toBe(400); - expect(data.error).toBe('streamId or messageId is required'); + expect(data.error).toBe('streamId, messageId or conversationId is required'); }); it('returns 400 when streamId is empty string and no messageId', async () => { @@ -132,7 +193,7 @@ describe('POST /api/ai/abort', () => { const data = await response.json(); expect(response.status).toBe(400); - expect(data.error).toBe('streamId or messageId is required'); + expect(data.error).toBe('streamId, messageId or conversationId is required'); }); it('returns 400 when streamId is whitespace only and no messageId', async () => { @@ -144,7 +205,7 @@ describe('POST /api/ai/abort', () => { const data = await response.json(); expect(response.status).toBe(400); - expect(data.error).toBe('streamId or messageId is required'); + expect(data.error).toBe('streamId, messageId or conversationId is required'); expect(abortStream).not.toHaveBeenCalled(); }); }); @@ -249,7 +310,7 @@ describe('POST /api/ai/abort', () => { const data = await response.json(); expect(response.status).toBe(400); - expect(data.error).toBe('streamId or messageId is required'); + expect(data.error).toBe('streamId, messageId or conversationId is required'); }); }); diff --git a/apps/web/src/app/api/ai/abort/route.ts b/apps/web/src/app/api/ai/abort/route.ts index c61b39f32e..945ef2a9d9 100644 --- a/apps/web/src/app/api/ai/abort/route.ts +++ b/apps/web/src/app/api/ai/abort/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; import { abortStream, abortStreamByMessageId } from '@/lib/ai/core/stream-abort-registry'; +import { abortConversationStreams } from '@/lib/ai/core/abort-conversation-streams'; import { loggers } from '@pagespace/lib/logging/logger-config'; import { auditRequest } from '@pagespace/lib/audit/audit-log'; import { checkDistributedRateLimit } from '@pagespace/lib/security/distributed-rate-limit'; @@ -48,23 +49,44 @@ export async function POST(request: Request) { } const body = await request.json(); - const { streamId, messageId } = body as { streamId?: string; messageId?: string }; + const { streamId, messageId, conversationId } = body as { + streamId?: string; + messageId?: string; + conversationId?: string; + }; const hasStreamId = streamId && typeof streamId === 'string' && streamId.trim(); const hasMessageId = messageId && typeof messageId === 'string' && messageId.trim(); + // The one name the client holds from t=0. Both streamId and messageId are minted SERVER-side + // and are unknown until the response headers land — so a Stop pressed inside the 0.5-3s TTFB + // window (auth, rate limit, DB reads, context assembly, provider connect) had NOTHING to + // name. It cancelled the local fetch and returned; streams are deliberately server-owned and + // survive a client disconnect, so the generation kept running, kept calling write tools, and + // kept billing, while the button flipped back to Send. See abort-conversation-streams.ts. + const hasConversationId = + conversationId && typeof conversationId === 'string' && conversationId.trim(); - if (!hasStreamId && !hasMessageId) { + if (!hasStreamId && !hasMessageId && !hasConversationId) { return NextResponse.json( - { error: 'streamId or messageId is required' }, + { error: 'streamId, messageId or conversationId is required' }, { status: 400 } ); } + // messageId is the most precise name, then streamId; conversationId is the fallback that + // works before either exists. const result = hasMessageId ? abortStreamByMessageId({ messageId: messageId as string, userId }) - : abortStream({ streamId: streamId as string, userId }); + : hasStreamId + ? abortStream({ streamId: streamId as string, userId }) + : await abortConversationStreams({ conversationId: conversationId as string, userId }) + .then((r) => ({ aborted: r.aborted.length > 0, reason: r.reason })); - const resourceId = hasMessageId ? (messageId as string) : (streamId as string); + const resourceId = hasMessageId + ? (messageId as string) + : hasStreamId + ? (streamId as string) + : (conversationId as string); loggers.api.info('AI stream abort requested', { streamId, diff --git a/apps/web/src/app/api/ai/chat/__tests__/stream-socket-events.test.ts b/apps/web/src/app/api/ai/chat/__tests__/stream-socket-events.test.ts index 0a6f6367ca..0afff25266 100644 --- a/apps/web/src/app/api/ai/chat/__tests__/stream-socket-events.test.ts +++ b/apps/web/src/app/api/ai/chat/__tests__/stream-socket-events.test.ts @@ -16,6 +16,8 @@ const { mockBroadcastChatUserMessage, mockSaveMessageToDatabase, mockGetConversation, + mockHasConflictingMessageOwner, + mockTakeOverConversationStreams, mockCreateConversation, } = vi.hoisted(() => ({ mockCreateStreamLifecycle: vi.fn(), @@ -24,6 +26,8 @@ const { mockBroadcastChatUserMessage: vi.fn().mockResolvedValue(undefined), mockSaveMessageToDatabase: vi.fn().mockResolvedValue(undefined), mockGetConversation: vi.fn().mockResolvedValue(null), // default: legacy (no row) → broadcast + mockHasConflictingMessageOwner: vi.fn().mockResolvedValue(false), + mockTakeOverConversationStreams: vi.fn().mockResolvedValue({ aborted: [], reconciled: [] }), mockCreateConversation: vi.fn().mockResolvedValue(undefined), })); @@ -42,6 +46,10 @@ const captured = vi.hoisted(() => ({ streamTextOptions: {} as MockStreamTextOptions, })); +vi.mock('@/lib/ai/core/stream-takeover', () => ({ + takeOverConversationStreams: mockTakeOverConversationStreams, +})); + vi.mock('@/lib/ai/core/stream-lifecycle', () => ({ createStreamLifecycle: mockCreateStreamLifecycle, })); @@ -195,7 +203,8 @@ vi.mock('ai', () => ({ createUIMessageStreamResponse: vi.fn().mockReturnValue(new Response('', { status: 200 })), })); -vi.mock('@paralleldrive/cuid2', () => ({ +vi.mock('@paralleldrive/cuid2', async (importOriginal) => ({ + ...(await importOriginal()), createId: vi.fn().mockReturnValue('test-message-id'), init: vi.fn(() => vi.fn(() => 'test-cuid')), })); @@ -207,6 +216,7 @@ vi.mock('@/lib/logging/mask', () => ({ vi.mock('@/lib/repositories/conversation-repository', () => ({ conversationRepository: { getConversation: mockGetConversation, + hasConflictingMessageOwner: mockHasConflictingMessageOwner, createConversation: mockCreateConversation, }, })); @@ -308,6 +318,9 @@ const mockAuth = (): SessionAuthResult => ({ adminRoleVersion: 0, }); +// A real cuid: POST /api/ai/chat only ever CREATES a conversation from a cuid. +const CONV_ID = 'clhjx7xu5e4yhlvpfs3h7xea'; + const makeRequest = (overrides: { browserSessionId?: string | null; conversationId?: string } = {}) => { const headers: Record = { 'content-type': 'application/json', @@ -322,7 +335,7 @@ const makeRequest = (overrides: { browserSessionId?: string | null; conversation body: JSON.stringify({ messages: [{ id: 'msg_1', role: 'user', parts: [{ type: 'text', text: 'Hello' }] }], chatId: 'page-1', - conversationId: overrides.conversationId ?? 'conv-1', + conversationId: overrides.conversationId ?? CONV_ID, selectedProvider: 'openai', selectedModel: 'openai/gpt-5.3-chat', }), @@ -340,6 +353,14 @@ describe('POST /api/ai/chat — lifecycle handoff', () => { vi.clearAllMocks(); captured.createUIMessageStreamOptions = {}; captured.streamTextOptions = {}; + // clearAllMocks() clears CALLS, not IMPLEMENTATIONS. Seven tests below use mockResolvedValue + // (not ...Once), so without these two lines each one leaked its fixture into every later test + // — including the lifecycle-invocation test, which was silently running against an + // already-owned conversation instead of the intended "no row" default. It passed either way, + // which is exactly why nobody noticed. + mockGetConversation.mockResolvedValue(null); + mockHasConflictingMessageOwner.mockResolvedValue(false); + mockTakeOverConversationStreams.mockResolvedValue({ aborted: [], reconciled: [] }); vi.mocked(authenticateRequestWithOptions).mockResolvedValue(mockAuth()); mockCreateStreamLifecycle.mockResolvedValue({ pushPart: mockLifecyclePushPart, @@ -374,6 +395,157 @@ describe('POST /api/ai/chat — lifecycle handoff', () => { }); }); + // AC3 step 3. The client used to send a `${pageId}-default` sentinel for a brand-new + // chat and this route accepted it unvalidated, minting a real conversations row under + // that id — which the client then refused to load back, stranding the history. + describe('conversationId validation', () => { + it('given a non-cuid conversationId with no existing row, should 400 rather than create a conversation from it', async () => { + mockGetConversation.mockResolvedValueOnce(null); + + const response = await POST(makeRequest({ conversationId: 'page-1-default' })); + + expect(response.status).toBe(400); + expect(mockCreateStreamLifecycle).not.toHaveBeenCalled(); + }); + + // The migration-free recovery path: those sentinel rows EXIST in production, the + // client now loads them, and it will keep POSTing that id. A bare isCuid reject + // would lock those users out of the conversation we just gave them back. + it('given a legacy non-cuid conversationId that DOES exist, should accept it and stream normally', async () => { + mockGetConversation.mockResolvedValue({ id: 'page-1-default', userId: 'user-1', isShared: false, contextId: 'page-1' }); + + const response = await POST(makeRequest({ conversationId: 'page-1-default' })); + + expect(response.status).not.toBe(400); + expect(mockCreateStreamLifecycle).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: 'page-1-default' }), + ); + }); + + it('given a cuid conversationId with no existing row, should allow it (a cuid may always create)', async () => { + mockGetConversation.mockResolvedValue(null); + + await POST(makeRequest({ conversationId: CONV_ID })); + + expect(mockCreateStreamLifecycle).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: CONV_ID }), + ); + }); + }); + + // The history load is keyed on (pageId, conversationId) with NO user filter, so an id + // that resolves to someone else's conversation reads their private history into the + // model context and appends this user's message to it. `${pageId}-default` is derived + // from the page id, so it is *guessable* by any member with edit access — this guard + // is what stops that. + describe('conversationId authorization', () => { + it('given an existing conversation owned by ANOTHER user and not shared, should 403', async () => { + mockGetConversation.mockResolvedValue({ + id: 'page-1-default', userId: 'someone-else', isShared: false, contextId: 'page-1', + }); + + const response = await POST(makeRequest({ conversationId: 'page-1-default' })); + + expect(response.status).toBe(403); + expect(mockCreateStreamLifecycle).not.toHaveBeenCalled(); + }); + + it('given an existing conversation owned by another user but explicitly SHARED, should allow it AND propagate isShared', async () => { + mockGetConversation.mockResolvedValue({ + id: CONV_ID, userId: 'someone-else', isShared: true, contextId: 'page-1', + }); + + const response = await POST(makeRequest({ conversationId: CONV_ID })); + + expect(response.status).not.toBe(403); + // NOT just `toHaveBeenCalled()`. `isShared` rides the chat:stream_start broadcast, and + // useChannelStreamSocket DROPS a co-member's stream when it is false — so this flag is the + // sole signal that makes a shared conversation visible to anyone but its owner. Asserting + // only that the lifecycle was called let a hardcoded `false` pass every test in the suite + // while every shared conversation went dark for every co-member. + expect(mockCreateStreamLifecycle).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: CONV_ID, isShared: true }), + ); + }); + + it('given an existing conversation belonging to a DIFFERENT page, should 403', async () => { + mockGetConversation.mockResolvedValue({ + id: CONV_ID, userId: 'user-1', isShared: false, contextId: 'some-other-page', + }); + + const response = await POST(makeRequest({ conversationId: CONV_ID })); + + expect(response.status).toBe(403); + expect(mockCreateStreamLifecycle).not.toHaveBeenCalled(); + }); + + // Fail closed on the LEGACY shape too: a conversation whose messages predate the + // conversations table has messages under its id and NO row, so an existence-only + // check would skip the ownership guard entirely — letting a caller read another + // user's history into their model context, append to it, and (since takeover aborts + // as the stream's owner) abort its stream. + it('given a cuid with no conversations row but messages owned by ANOTHER user (legacy), should 403', async () => { + mockGetConversation.mockResolvedValue(null); + mockHasConflictingMessageOwner.mockResolvedValueOnce(true); + + const response = await POST(makeRequest({ conversationId: CONV_ID })); + + expect(response.status).toBe(403); + expect(mockCreateStreamLifecycle).not.toHaveBeenCalled(); + }); + + // contextId is nullable in the schema (null for global conversations). An owner must + // never be locked out of their own row by a historically-unset column. + it('given the caller OWNS a conversation whose contextId is null, should allow it', async () => { + mockGetConversation.mockResolvedValue({ + id: CONV_ID, userId: 'user-1', isShared: false, contextId: null, + }); + + const response = await POST(makeRequest({ conversationId: CONV_ID })); + + expect(response.status).not.toBe(403); + expect(mockCreateStreamLifecycle).toHaveBeenCalled(); + }); + }); + + // AC5 — takeover, never 409. This route called takeOverConversationStreams and NOTHING asserted + // it: the module was not even mocked, so the real function ran against a db mock with no + // .update(), threw, and was swallowed by its own catch. The call site could have been deleted + // and every test would still have passed. + describe('per-conversation takeover (AC5)', () => { + it('takes over the conversation before starting a new generation', async () => { + await POST(makeRequest({ browserSessionId: 'session-7' })); + + expect(mockTakeOverConversationStreams).toHaveBeenCalledWith({ + conversationId: CONV_ID, + channelId: 'page-1', + }); + }); + + it('takes over BEFORE creating the new lifecycle — the other order would abort the stream it just started', async () => { + await POST(makeRequest({ browserSessionId: 'session-7' })); + + const takeoverAt = mockTakeOverConversationStreams.mock.invocationCallOrder[0]; + const lifecycleAt = mockCreateStreamLifecycle.mock.invocationCallOrder[0]; + expect(takeoverAt).toBeLessThan(lifecycleAt); + }); + + // Never 409. A rejection would SELF-LOCK the conversation: the terminal status write is + // fire-and-forget and dies with its process, so a crashed generation leaves a permanently + // 'streaming' row. The user would be locked out of their own chat. + it('given a stream was already in flight, still proceeds — takeover, not rejection', async () => { + mockTakeOverConversationStreams.mockResolvedValue({ + aborted: ['msg-previous'], + reconciled: ['msg-previous'], + }); + + const response = await POST(makeRequest({ browserSessionId: 'session-7' })); + + expect(response.status).not.toBe(409); + expect(mockCreateStreamLifecycle).toHaveBeenCalledTimes(1); + }); + }); + describe('createStreamLifecycle invocation', () => { it('given a new AI stream, should construct the lifecycle with channel, conversation, user, displayName, and browserSessionId', async () => { await POST(makeRequest({ browserSessionId: 'session-7' })); @@ -382,24 +554,25 @@ describe('POST /api/ai/chat — lifecycle handoff', () => { expect(mockCreateStreamLifecycle).toHaveBeenCalledWith({ messageId: 'test-message-id', channelId: 'page-1', - conversationId: 'conv-1', + conversationId: CONV_ID, userId: 'user-1', displayName: 'Profile User', browserSessionId: 'session-7', + isShared: false, }); }); }); describe('user-message broadcast', () => { it('given a POST with a user message, should broadcast chat:user_message after the DB save resolves with the saved message and full envelope', async () => { - mockGetConversation.mockResolvedValueOnce({ id: 'conv-1', userId: 'user-1', isShared: true }); + mockGetConversation.mockResolvedValueOnce({ id: CONV_ID, userId: 'user-1', isShared: true }); await POST(makeRequest({ browserSessionId: 'session-7' })); expect(mockBroadcastChatUserMessage).toHaveBeenCalledTimes(1); expect(mockBroadcastChatUserMessage).toHaveBeenCalledWith({ message: { id: 'msg_1', role: 'user', parts: [{ type: 'text', text: 'Hello' }] }, pageId: 'page-1', - conversationId: 'conv-1', + conversationId: CONV_ID, triggeredBy: { userId: 'user-1', displayName: 'Profile User', browserSessionId: 'session-7' }, }); }); @@ -424,47 +597,47 @@ describe('POST /api/ai/chat — lifecycle handoff', () => { mockCreateConversation.mockRejectedValueOnce(new Error('db down')); mockGetConversation.mockResolvedValueOnce(null); - await POST(makeRequest({ conversationId: 'conv-1' })); + await POST(makeRequest({ conversationId: CONV_ID })); expect(mockBroadcastChatUserMessage).not.toHaveBeenCalled(); }); it('should broadcast when conversation isShared is true', async () => { mockGetConversation.mockResolvedValueOnce({ - id: 'conv-1', userId: 'other-user', isShared: true, + id: CONV_ID, userId: 'other-user', isShared: true, }); - await POST(makeRequest({ conversationId: 'conv-1' })); + await POST(makeRequest({ conversationId: CONV_ID })); expect(mockBroadcastChatUserMessage).toHaveBeenCalledTimes(1); }); it('should suppress broadcast when user owns a private conversation', async () => { mockGetConversation.mockResolvedValueOnce({ - id: 'conv-1', userId: 'user-1', isShared: false, + id: CONV_ID, userId: 'user-1', isShared: false, }); - await POST(makeRequest({ conversationId: 'conv-1' })); + await POST(makeRequest({ conversationId: CONV_ID })); expect(mockBroadcastChatUserMessage).not.toHaveBeenCalled(); }); it('should suppress broadcast when private conversation is owned by someone else', async () => { mockGetConversation.mockResolvedValueOnce({ - id: 'conv-1', userId: 'other-user', isShared: false, + id: CONV_ID, userId: 'other-user', isShared: false, }); - await POST(makeRequest({ conversationId: 'conv-1' })); + await POST(makeRequest({ conversationId: CONV_ID })); expect(mockBroadcastChatUserMessage).not.toHaveBeenCalled(); }); it('should omit mentionNotify from saveMessageToDatabase when isShared=false', async () => { mockGetConversation.mockResolvedValueOnce({ - id: 'conv-1', userId: 'user-1', isShared: false, + id: CONV_ID, userId: 'user-1', isShared: false, }); - await POST(makeRequest({ conversationId: 'conv-1' })); + await POST(makeRequest({ conversationId: CONV_ID })); await captured.createUIMessageStreamOptions.onFinish?.({ responseMessage: mockResponseMessage }); const saveCalls = mockSaveMessageToDatabase.mock.calls; @@ -474,10 +647,10 @@ describe('POST /api/ai/chat — lifecycle handoff', () => { it('should include mentionNotify in saveMessageToDatabase when isShared=true', async () => { mockGetConversation.mockResolvedValueOnce({ - id: 'conv-1', userId: 'user-1', isShared: true, + id: CONV_ID, userId: 'user-1', isShared: true, }); - await POST(makeRequest({ conversationId: 'conv-1' })); + await POST(makeRequest({ conversationId: CONV_ID })); await captured.createUIMessageStreamOptions.onFinish?.({ responseMessage: mockResponseMessage }); const saveCalls = mockSaveMessageToDatabase.mock.calls; @@ -588,7 +761,7 @@ describe('POST /api/ai/chat — lifecycle handoff', () => { expect(assistantSave?.[0]).toMatchObject({ messageId: 'test-message-id', pageId: 'page-1', - conversationId: 'conv-1', + conversationId: CONV_ID, userId: null, role: 'assistant', }); diff --git a/apps/web/src/app/api/ai/chat/active-streams/__tests__/route.test.ts b/apps/web/src/app/api/ai/chat/active-streams/__tests__/route.test.ts index 5c05f5b0de..c93422b607 100644 --- a/apps/web/src/app/api/ai/chat/active-streams/__tests__/route.test.ts +++ b/apps/web/src/app/api/ai/chat/active-streams/__tests__/route.test.ts @@ -34,6 +34,7 @@ vi.mock('@pagespace/db/schema/ai-streams', () => ({ channelId: 'channelId', status: 'status', startedAt: 'startedAt', + lastHeartbeatAt: 'lastHeartbeatAt', }, })); @@ -42,6 +43,14 @@ vi.mock('@/lib/auth', () => ({ isAuthError: vi.fn(), })); +// Conversation-scoped subscription authorization. Page access alone would hand another +// member's PRIVATE conversation (and its buffered parts snapshot) to anyone who can view +// the page — see stream-subscription-authz.ts, which is unit-tested separately. +const mockFilterSubscribableStreams = vi.fn(); +vi.mock('@/lib/ai/core/stream-subscription-authz', () => ({ + filterSubscribableStreams: (args: { rows: unknown[] }) => mockFilterSubscribableStreams(args), +})); + vi.mock('@pagespace/lib/permissions/permissions', () => ({ canUserViewPage: vi.fn(), })); @@ -89,6 +98,8 @@ describe('GET /api/ai/chat/active-streams', () => { vi.mocked(authenticateRequestWithOptions).mockResolvedValue(mockSessionAuth()); vi.mocked(isAuthError).mockReturnValue(false); vi.mocked(canUserViewPage).mockResolvedValue(true); + // Default: everything the liveness filter kept is subscribable (the caller's own). + mockFilterSubscribableStreams.mockImplementation(async ({ rows }: { rows: unknown[] }) => rows); }); it('given rows with a persisted parts snapshot, should pass them through on each stream', async () => { @@ -102,6 +113,7 @@ describe('GET /api/ai/chat/active-streams', () => { browserSessionId: 'session-2', parts, startedAt: new Date('2024-01-01T00:00:00.000Z'), + lastHeartbeatAt: new Date(), }, ]); @@ -133,6 +145,7 @@ describe('GET /api/ai/chat/active-streams', () => { browserSessionId: 'session-2', parts: null, startedAt: new Date('2024-01-01T00:00:00.000Z'), + lastHeartbeatAt: new Date(), }, ]); @@ -142,6 +155,97 @@ describe('GET /api/ai/chat/active-streams', () => { expect(body.streams[0].parts).toEqual([]); }); + // A crashed generation leaves status='streaming' forever — the terminal write is + // fire-and-forget and dies with the process. Serving that row would give every + // subscriber a phantom in-progress bubble, with a Stop button, for a dead stream. + it('given a streaming row whose heartbeat is stale (crashed process), should NOT serve it as an active stream', async () => { + mockOrderBy.mockResolvedValueOnce([ + { + messageId: 'msg-dead', + conversationId: 'conv-1', + userId: 'user-2', + displayName: 'Alice', + browserSessionId: 'session-2', + parts: [], + startedAt: new Date(Date.now() - 5 * 60 * 1000), + lastHeartbeatAt: new Date(Date.now() - 5 * 60 * 1000), + }, + ]); + + const response = await GET(makeRequest()); + const body = await response.json(); + + expect(body.streams).toEqual([]); + }); + + // The endpoint used to also cap on `startedAt >= now-10min`. That cap had to go: this + // response is the authoritative answer to "what is still running on this channel" + // (consumers release their Stop-slot claims against it), and the cap made it LIE about + // exactly the streams where Stop matters most — a deep-research or long tool-loop run is + // still very much alive at minute 12. Dropping it here told every subscriber it had + // ended, killing the Stop button on a stream that kept generating and kept billing. + it('given a stream that started LONG ago but is still beating, should serve it (liveness is the heartbeat, not age)', async () => { + mockOrderBy.mockResolvedValueOnce([ + { + messageId: 'msg-long-run', + conversationId: 'conv-1', + userId: 'user-1', + displayName: 'Me', + browserSessionId: 'session-1', + parts: [], + startedAt: new Date(Date.now() - 45 * 60 * 1000), + lastHeartbeatAt: new Date(), + }, + ]); + + const response = await GET(makeRequest()); + const body = await response.json(); + + expect(body.streams.map((s: { messageId: string }) => s.messageId)).toEqual(['msg-long-run']); + }); + + it('given a streaming row that is still checkpointing, should serve it', async () => { + mockOrderBy.mockResolvedValueOnce([ + { + messageId: 'msg-live', + conversationId: 'conv-1', + userId: 'user-2', + displayName: 'Alice', + browserSessionId: 'session-2', + parts: [], + startedAt: new Date(Date.now() - 5 * 60 * 1000), + lastHeartbeatAt: new Date(Date.now() - 10 * 1000), + }, + ]); + + const response = await GET(makeRequest()); + const body = await response.json(); + + expect(body.streams.map((s: { messageId: string }) => s.messageId)).toEqual(['msg-live']); + }); + + it("given a live stream the caller may not subscribe to (another member's PRIVATE conversation), should not return it", async () => { + mockOrderBy.mockResolvedValueOnce([ + { + messageId: 'msg-theirs', + conversationId: 'their-private-conv', + userId: 'user-other', + displayName: 'Alice', + browserSessionId: 'session-2', + parts: [{ type: 'text', text: 'private content' }], + startedAt: new Date(), + lastHeartbeatAt: new Date(), + }, + ]); + // Conversation-scoped authorization drops it: page access is not enough. + mockFilterSubscribableStreams.mockResolvedValueOnce([]); + + const response = await GET(makeRequest()); + const body = await response.json(); + + expect(body.streams).toEqual([]); + }); + it('given no active streams, should return an empty streams array', async () => { mockOrderBy.mockResolvedValueOnce([]); diff --git a/apps/web/src/app/api/ai/chat/active-streams/route.ts b/apps/web/src/app/api/ai/chat/active-streams/route.ts index 27a5a8a96e..ffff49a955 100644 --- a/apps/web/src/app/api/ai/chat/active-streams/route.ts +++ b/apps/web/src/app/api/ai/chat/active-streams/route.ts @@ -2,11 +2,13 @@ import { NextResponse } from 'next/server'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; import { canUserViewPage } from '@pagespace/lib/permissions/permissions'; import { db } from '@pagespace/db/db'; -import { and, asc, eq, gte } from '@pagespace/db/operators'; +import { and, asc, eq } from '@pagespace/db/operators'; import { aiStreamSessions } from '@pagespace/db/schema/ai-streams'; import { loggers } from '@pagespace/lib/logging/logger-config'; import { auditRequest } from '@pagespace/lib/audit/audit-log'; import { parseGlobalChannelId } from '@pagespace/lib/ai/global-channel-id'; +import { isStreamRowLive } from '@/lib/ai/core/stream-liveness'; +import { filterSubscribableStreams } from '@/lib/ai/core/stream-subscription-authz'; const AUTH_OPTIONS = { allow: ['session'] as const, requireCSRF: false }; @@ -60,8 +62,21 @@ export async function GET(request: Request) { } } - const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); - const streams = await db + // Liveness is the heartbeat, and ONLY the heartbeat. + // + // There used to also be a `startedAt >= now - 10min` cap here, from before the + // heartbeat existed — a crude proxy for "probably not a crashed row". It has to go: + // this response is the authoritative answer to "what is still running on this + // channel" (consumers reconcile their Stop-slot claims against it), and the cap made + // that answer LIE about exactly the streams where it matters most. A deep-research or + // long tool-loop generation is still very much alive at minute 12 — and dropping it + // here would tell every subscriber it had ended, releasing the Stop button on a stream + // that keeps generating and keeps billing. + // + // The heartbeat already covers what the cap was for: a crashed process stops beating, + // and isStreamRowLive filters it out within STREAM_HEARTBEAT_STALE_MS. + const now = Date.now(); + const rows = await db .select({ messageId: aiStreamSessions.messageId, conversationId: aiStreamSessions.conversationId, @@ -70,17 +85,25 @@ export async function GET(request: Request) { browserSessionId: aiStreamSessions.browserSessionId, parts: aiStreamSessions.parts, startedAt: aiStreamSessions.startedAt, + lastHeartbeatAt: aiStreamSessions.lastHeartbeatAt, }) .from(aiStreamSessions) .where( and( eq(aiStreamSessions.channelId, channelId), eq(aiStreamSessions.status, 'streaming'), - gte(aiStreamSessions.startedAt, tenMinutesAgo), ) ) .orderBy(asc(aiStreamSessions.startedAt), asc(aiStreamSessions.messageId)); + // Page access is not enough. A page channel carries EVERY conversation on the page, + // and conversations are private by default — so returning every streaming row (with + // its buffered `parts` snapshot!) to anyone who can view the page hands one member's + // private conversation to all the others. Narrow to what this user may subscribe to: + // their own streams, plus streams in explicitly shared conversations. + const live = rows.filter((r) => isStreamRowLive(r, now)); + const streams = await filterSubscribableStreams({ userId, rows: live }); + return NextResponse.json({ streams: streams.map((s) => ({ messageId: s.messageId, diff --git a/apps/web/src/app/api/ai/chat/route.ts b/apps/web/src/app/api/ai/chat/route.ts index e2e363fd85..2eb41fcc49 100644 --- a/apps/web/src/app/api/ai/chat/route.ts +++ b/apps/web/src/app/api/ai/chat/route.ts @@ -32,6 +32,7 @@ import { creditGateErrorResponse } from '@/lib/subscription/credit-gate-response import type { SubscriptionTier } from '@pagespace/lib/services/subscription-utils'; import { broadcastChatUserMessage } from '@/lib/websocket'; import { createStreamLifecycle, type StreamLifecycleHandle } from '@/lib/ai/core/stream-lifecycle'; +import { takeOverConversationStreams } from '@/lib/ai/core/stream-takeover'; import { chunkToPart } from '@/lib/ai/streams/chunkToPart'; import { validateBrowserSessionIdHeader } from '@/lib/ai/core/browser-session-id-validation'; import { authenticateRequestWithOptions, isAuthError, isMCPAuthResult, checkMCPPageScope, getAllowedDriveIds, isScopedMCPAuth, canPrincipalViewPage, canPrincipalEditPage } from '@/lib/auth'; @@ -87,7 +88,7 @@ import { eq, and } from '@pagespace/db/operators' import { users } from '@pagespace/db/schema/auth' import { chatMessages, pages, drives } from '@pagespace/db/schema/core'; import { userProfiles } from '@pagespace/db/schema/members'; -import { createId } from '@paralleldrive/cuid2'; +import { createId, isCuid } from '@paralleldrive/cuid2'; import { loggers } from '@pagespace/lib/logging/logger-config'; import { auditRequest } from '@pagespace/lib/audit/audit-log'; import { maskIdentifier } from '@/lib/logging/mask'; @@ -387,6 +388,79 @@ export async function POST(request: Request) { hasDrivePrompt: !!drivePromptPrefix }); + // conversationId is caller-supplied, and the history load below is keyed on + // (pageId, conversationId) with NO user filter — so an id that resolves to + // someone else's conversation reads their private history into the model context + // and appends this user's message to it. Two rules, both enforced here: + // + // 1. A conversation may only ever be CREATED from a cuid. The client used to + // send a `${pageId}-default` sentinel for a brand-new chat and this route + // accepted it unvalidated, minting a real conversations row under it — which + // the client then refused to load, stranding the history. Those rows exist in + // production and the client now loads and keeps using them, so a bare isCuid + // reject would lock those users out of the history we just gave them back. + // Hence: a non-cuid id is accepted only if its row ALREADY exists. + // + // 2. An EXISTING conversation must be one this user may actually write to — + // their own, or an explicitly shared one — and must belong to this page. + // Without this, `${pageId}-default` is a guessable id (it is derived from the + // page id) that any member with edit access could use to read a co-member's + // private conversation. Conversations are private by default. + let existingConversation: Awaited> = null; + if (requestConversationId) { + // Deliberately un-caught. A DB error here must not degrade into "no row exists", + // which is the branch that lets a fresh cuid through — an authorization check that + // fails open on a blip is not a check. A throw lands in the route's 500 handler. + existingConversation = await conversationRepository.getConversation(requestConversationId); + + if (!existingConversation) { + if (!isCuid(requestConversationId)) { + loggers.ai.warn('AI Chat API: rejected non-cuid conversationId with no existing row', { + userId, + requestConversationId, + }); + return NextResponse.json({ error: 'Invalid conversationId' }, { status: 400 }); + } + // No `conversations` row does NOT prove the conversation is new. A LEGACY + // conversation (messages written before the conversations table was populated) + // has messages under its id and no row — and the ownership check below would be + // skipped for it entirely, so a caller supplying someone else's legacy cuid would + // read that history into their model context, append to it, and now (since + // takeover aborts as the stream's owner) be able to abort its stream too. Fail + // closed on the same signal `createConversation` uses for the row itself. + // No `.catch(() => false)` here: this is an authorization check, and swallowing a + // DB error into "no conflict" would fail OPEN on exactly the blip an attacker + // would like to cause. A throw here lands in the route's 500 handler. + const hasConflictingOwner = await conversationRepository + .hasConflictingMessageOwner(requestConversationId, userId!); + if (hasConflictingOwner) { + loggers.ai.warn('AI Chat API: rejected legacy conversationId owned by another user', { + userId, + requestConversationId, + }); + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } else { + const ownsIt = existingConversation.userId === userId; + const isSharedConversation = existingConversation.isShared === true; + // contextId is nullable in the schema (null for global conversations), so only + // enforce the page match when it is actually set — an owner must never be + // locked out of their own row by a historically-unset column. + const belongsToThisPage = + !existingConversation.contextId || existingConversation.contextId === chatId; + if ((!ownsIt && !isSharedConversation) || !belongsToThisPage) { + loggers.ai.warn('AI Chat API: rejected conversationId the caller may not write to', { + userId, + requestConversationId, + ownsIt, + isSharedConversation, + belongsToThisPage, + }); + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + } + // Auto-generate conversationId if not provided (seamless UX) conversationId = requestConversationId || createId(); loggers.ai.debug('AI Chat API: Conversation session', { @@ -1099,11 +1173,15 @@ export async function POST(request: Request) { const [userProfile] = await userProfilePromise; const displayName = userProfile?.displayName ?? user?.name ?? 'Someone'; + // Reuse the row the conversationId validation above already fetched. A conversation + // that did NOT exist then was created by this request, so it is private by + // definition (createConversation inserts isShared: false) — which is also the + // fail-closed answer. Saves a second (and third) read of the same row per message. + isConversationShared = existingConversation?.isShared === true; + if (userMessage && userMessage.role === 'user') { // Only broadcast to the page channel if the conversation is explicitly shared. // Fail closed: no broadcast if the row is missing or private. - const convRow = await conversationRepository.getConversation(conversationId!).catch(() => null); - isConversationShared = convRow?.isShared === true; const shouldBroadcast = isConversationShared; if (shouldBroadcast) { broadcastChatUserMessage({ @@ -1113,16 +1191,32 @@ export async function POST(request: Request) { triggeredBy: { userId: userId!, displayName, browserSessionId }, }).catch(() => {}); } - } else if (userMessage?.role === 'assistant') { - // ask_user resume turn: no new user message to broadcast, but - // isConversationShared still gates the mention-notify call below for - // the agent's reply — must still be computed here, or it silently - // stays at its initial `false` and mention notifications are dropped - // for every turn that follows an answered ask_user question. - const convRow = await conversationRepository.getConversation(conversationId!).catch(() => null); - isConversationShared = convRow?.isShared === true; } + // Per-conversation in-flight guard. A second send takes the conversation OVER — aborts + // whatever is live, reconciles its row — rather than being rejected, because a row whose + // terminal write never landed (crashed process; the write is fire-and-forget) would + // otherwise lock the user out of their own chat. See stream-liveness.ts. + // + // KNOWN RACE — this does NOT guarantee "at most one generation per conversation", and this + // comment used to claim that it did. It is a check-then-act with no serialization: the SELECT + // inside takeOverConversationStreams and the INSERT inside createStreamLifecycle (a few + // statements below) are not atomic together. Two near-simultaneous sends can BOTH see zero + // in-flight rows and BOTH proceed — two generations, two sets of tool calls, two bills. + // + // Deliberately not closed here: it needs DB-level serialization (an advisory lock spanning + // takeover+insert, or a partial unique index on (conversation_id) WHERE status='streaming' — + // whose migration would fail outright on any pre-existing duplicate rows, so it needs a + // reconciliation step first). That is its own change with its own migration risk, and `master` + // has no takeover at all — concurrent sends there ALWAYS double-generate — so this is a strict + // improvement, not a regression. + // + // Do not read what follows as if the race were closed. It is narrow, not absent. + await takeOverConversationStreams({ + conversationId: conversationId!, + channelId: chatId, + }); + lifecycle = await createStreamLifecycle({ messageId: serverAssistantMessageId, channelId: chatId, @@ -1130,6 +1224,7 @@ export async function POST(request: Request) { userId: userId!, displayName, browserSessionId, + isShared: isConversationShared, }); try { diff --git a/apps/web/src/app/api/ai/chat/stream-join/[messageId]/__tests__/route.test.ts b/apps/web/src/app/api/ai/chat/stream-join/[messageId]/__tests__/route.test.ts index 9f1eb50cdb..733dd388e8 100644 --- a/apps/web/src/app/api/ai/chat/stream-join/[messageId]/__tests__/route.test.ts +++ b/apps/web/src/app/api/ai/chat/stream-join/[messageId]/__tests__/route.test.ts @@ -37,6 +37,11 @@ import { canUserViewPage } from '@pagespace/lib/permissions/permissions'; import { auditRequest } from '@pagespace/lib/audit/audit-log'; const mockPageId = 'page-test-123'; +const mockCanSubscribeToStream = vi.fn(); +vi.mock('@/lib/ai/core/stream-subscription-authz', () => ({ + canSubscribeToStream: (args: unknown) => mockCanSubscribeToStream(args), +})); + const mockUserId = 'user-test-456'; const mockMessageId = 'msg-test-789'; const mockConversationId = 'conv-test-321'; @@ -88,6 +93,8 @@ describe('GET /api/ai/chat/stream-join/[messageId]', () => { vi.mocked(authenticateRequestWithOptions).mockResolvedValue(mockSessionAuth()); vi.mocked(isAuthError).mockReturnValue(false); + // Default: the caller owns the stream (canSubscribeToStream short-circuits on that). + mockCanSubscribeToStream.mockResolvedValue(true); vi.mocked(canUserViewPage).mockResolvedValue(true); }); @@ -195,6 +202,66 @@ describe('GET /api/ai/chat/stream-join/[messageId]', () => { }); }); + // Page access is NOT conversation access. A page room holds every member of the page, + // but conversations are private by default — `listConversations` shows you only + // `userId = you OR isShared`. Stream subscription now follows the same rule, so these + // are the two paths that matter and neither had route-level coverage before. + describe('conversation-scoped subscription', () => { + beforeEach(() => { + testRegistry.register(mockMessageId, mockMeta); + }); + + it("given another member's stream in an explicitly SHARED conversation, should still join (multiplayer must not regress)", async () => { + vi.mocked(authenticateRequestWithOptions).mockResolvedValue(mockSessionAuth('user-other')); + vi.mocked(canUserViewPage).mockResolvedValue(true); + mockCanSubscribeToStream.mockResolvedValue(true); + + const response = await GET(makeRequest(), makeContext(mockMessageId)); + + expect(response.status).toBe(200); + expect(mockCanSubscribeToStream).toHaveBeenCalledWith({ + userId: 'user-other', + streamOwnerId: mockUserId, + conversationId: mockConversationId, + }); + }); + + it("given another member's stream in a PRIVATE conversation, should NOT serve it", async () => { + vi.mocked(authenticateRequestWithOptions).mockResolvedValue(mockSessionAuth('user-other')); + vi.mocked(canUserViewPage).mockResolvedValue(true); + mockCanSubscribeToStream.mockResolvedValue(false); + + const response = await GET(makeRequest(), makeContext(mockMessageId)); + + expect(response.status).toBe(404); + }); + + // Deliberately a 404, not an audited 403. A member asking for a co-member's private + // stream is the ordinary consequence of a page-wide broadcast, not an attack — + // auditing it would write an authz-denial row per member per assistant message and + // bury real signal. A genuine page-access violation still 403s and still audits. + it('given a non-subscribable stream, should NOT write an authz-denial audit row', async () => { + vi.mocked(authenticateRequestWithOptions).mockResolvedValue(mockSessionAuth('user-other')); + vi.mocked(canUserViewPage).mockResolvedValue(true); + mockCanSubscribeToStream.mockResolvedValue(false); + + await GET(makeRequest(), makeContext(mockMessageId)); + + expect(vi.mocked(auditRequest)).not.toHaveBeenCalled(); + }); + + it('given the caller has no page access at all, should still 403 AND audit', async () => { + vi.mocked(authenticateRequestWithOptions).mockResolvedValue(mockSessionAuth('user-other')); + vi.mocked(canUserViewPage).mockResolvedValue(false); + mockCanSubscribeToStream.mockResolvedValue(true); + + const response = await GET(makeRequest(), makeContext(mockMessageId)); + + expect(response.status).toBe(403); + expect(vi.mocked(auditRequest)).toHaveBeenCalled(); + }); + }); + describe('SSE streaming', () => { it('given a valid messageId and authorized viewer, should return SSE response headers', async () => { testRegistry.register(mockMessageId, mockMeta); @@ -346,6 +413,34 @@ describe('GET /api/ai/chat/stream-join/[messageId]', () => { expect(body).not.toContain('after-revoke'); }); + // THE OTHER HALF OF THE BACKSTOP. `hasViewAccess()` is `pageOk && canSubscribe()`, and every + // test in this block only ever varied canUserViewPage — canSubscribeToStream was pinned true + // in beforeEach and never flipped. So `return canSubscribe();` could be deleted from + // hasViewAccess and the whole suite stayed green, while a conversation UN-SHARED mid-stream + // kept streaming, token by token, to someone who may no longer read it. Page access and + // conversation access are revoked independently; both halves must hold, on every tick. + it('given the conversation is UN-SHARED mid-stream, should abort the join even though page access still holds', async () => { + vi.useFakeTimers(); + vi.mocked(canUserViewPage).mockResolvedValue(true); // page access never lapses + let subscribable = true; + mockCanSubscribeToStream.mockImplementation(async () => subscribable); + testRegistry.register(mockMessageId, mockMeta); + + const response = await GET(makeRequest(), makeContext(mockMessageId)); + expect(response.status).toBe(200); + + // The owner flips the conversation back to private while this subscriber is mid-stream. + subscribable = false; + await vi.advanceTimersByTimeAsync(RECHECK_INTERVAL_MS); + + testRegistry.push(mockMessageId, { type: 'text', text: 'after-unshare' }); + + const body = await readSSEBody(response); + + expect(body).toContain('data: {"done":true,"aborted":true}\n\n'); + expect(body).not.toContain('after-unshare'); + }); + it('given permission is revoked mid-stream, should unsubscribe from the registry', async () => { vi.useFakeTimers(); let allowed = true; diff --git a/apps/web/src/app/api/ai/chat/stream-join/[messageId]/route.ts b/apps/web/src/app/api/ai/chat/stream-join/[messageId]/route.ts index 70e0f21b3a..c703abb220 100644 --- a/apps/web/src/app/api/ai/chat/stream-join/[messageId]/route.ts +++ b/apps/web/src/app/api/ai/chat/stream-join/[messageId]/route.ts @@ -4,6 +4,7 @@ import { canUserViewPage } from '@pagespace/lib/permissions/permissions'; import { streamMulticastRegistry } from '@/lib/ai/core/stream-multicast-registry'; import { auditRequest } from '@pagespace/lib/audit/audit-log'; import { parseGlobalChannelId } from '@pagespace/lib/ai/global-channel-id'; +import { canSubscribeToStream } from '@/lib/ai/core/stream-subscription-authz'; export const dynamic = 'force-dynamic'; @@ -40,11 +41,16 @@ export async function GET( } const channelOwner = parseGlobalChannelId(meta.pageId); - const hasViewAccess = async (): Promise => - channelOwner !== null ? channelOwner === userId : canUserViewPage(userId, meta.pageId); - - const canView = await hasViewAccess(); - if (!canView) { + // Page access, then conversation access. A page channel carries every conversation on + // the page and conversations are private by default, so page access alone would let + // any member join — and receive the tokens of — another member's PRIVATE conversation. + // You may join a stream you started, or one in an explicitly shared conversation. + const hasPageAccess = channelOwner !== null + ? channelOwner === userId + : await canUserViewPage(userId, meta.pageId); + + if (!hasPageAccess) { + // A genuine authorization violation: this user has no business on this page at all. auditRequest(request, { eventType: 'authz.access.denied', resourceType: 'ai_stream', @@ -55,6 +61,34 @@ export async function GET( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + // Page access is not conversation access. A page room holds every member, but + // conversations are private by default — the same rule listConversations enforces + // (`userId = you OR isShared`). A member asking for a co-member's private stream is + // not an attacker; it is the ordinary consequence of a page-wide broadcast. So this + // is a plain 404 — the stream genuinely does not exist *for them* — and NOT an + // audited 403, which would write an authz-denial row per member per assistant message + // and bury real signal. Clients already treat a failed join as benign. + const canSubscribe = async (): Promise => canSubscribeToStream({ + userId, + streamOwnerId: meta.userId, + conversationId: meta.conversationId, + }); + + if (!(await canSubscribe())) { + return NextResponse.json({ error: 'Stream not found' }, { status: 404 }); + } + + // Re-checked periodically for the life of the stream (the revocation backstop below). + // Both halves must hold: page access can be revoked, and a shared conversation can be + // un-shared, mid-stream. + const hasViewAccess = async (): Promise => { + const pageOk = channelOwner !== null + ? channelOwner === userId + : await canUserViewPage(userId, meta.pageId); + if (!pageOk) return false; + return canSubscribe(); + }; + const encoder = new TextEncoder(); const encodeDoneFrame = (aborted: boolean): Uint8Array => encoder.encode(`data: ${JSON.stringify({ done: true, aborted })}\n\n`); diff --git a/apps/web/src/app/api/ai/global/[id]/messages/__tests__/credit-gate.test.ts b/apps/web/src/app/api/ai/global/[id]/messages/__tests__/credit-gate.test.ts index 6cd2933474..a6fb3752a7 100644 --- a/apps/web/src/app/api/ai/global/[id]/messages/__tests__/credit-gate.test.ts +++ b/apps/web/src/app/api/ai/global/[id]/messages/__tests__/credit-gate.test.ts @@ -39,6 +39,10 @@ const captured = vi.hoisted(() => ({ steps: [] as unknown, })); +const { mockTakeOverConversationStreams } = vi.hoisted(() => ({ + mockTakeOverConversationStreams: vi.fn().mockResolvedValue({ aborted: [], reconciled: [] }), +})); + vi.mock('@/lib/ai/core/stream-lifecycle', () => ({ createStreamLifecycle: mockCreateStreamLifecycle, })); @@ -57,8 +61,16 @@ vi.mock('@/lib/auth', () => ({ isAuthError: vi.fn((result: unknown) => typeof result === 'object' && result !== null && 'error' in result), })); +vi.mock('@/lib/ai/core/stream-takeover', () => ({ + takeOverConversationStreams: mockTakeOverConversationStreams, +})); + vi.mock('@pagespace/lib/logging/logger-config', () => ({ loggers: { + ai: { + info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), trace: vi.fn(), + child: vi.fn(() => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), trace: vi.fn() })), + }, api: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), trace: vi.fn(), child: vi.fn(() => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), trace: vi.fn() })), diff --git a/apps/web/src/app/api/ai/global/[id]/messages/__tests__/stream-socket-events.test.ts b/apps/web/src/app/api/ai/global/[id]/messages/__tests__/stream-socket-events.test.ts index bf872c2ef6..3204dbf4f1 100644 --- a/apps/web/src/app/api/ai/global/[id]/messages/__tests__/stream-socket-events.test.ts +++ b/apps/web/src/app/api/ai/global/[id]/messages/__tests__/stream-socket-events.test.ts @@ -39,6 +39,10 @@ const captured = vi.hoisted(() => ({ streamTextOptions: {} as MockStreamTextOptions, })); +const { mockTakeOverConversationStreams } = vi.hoisted(() => ({ + mockTakeOverConversationStreams: vi.fn().mockResolvedValue({ aborted: [], reconciled: [] }), +})); + vi.mock('@/lib/ai/core/stream-lifecycle', () => ({ createStreamLifecycle: mockCreateStreamLifecycle, })); @@ -57,8 +61,16 @@ vi.mock('@/lib/auth', () => ({ isAuthError: vi.fn((result: unknown) => typeof result === 'object' && result !== null && 'error' in result), })); +vi.mock('@/lib/ai/core/stream-takeover', () => ({ + takeOverConversationStreams: mockTakeOverConversationStreams, +})); + vi.mock('@pagespace/lib/logging/logger-config', () => ({ loggers: { + ai: { + info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), trace: vi.fn(), + child: vi.fn(() => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), trace: vi.fn() })), + }, api: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), trace: vi.fn(), child: vi.fn(() => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), trace: vi.fn() })), @@ -409,6 +421,29 @@ describe('POST /api/ai/global/[id]/messages — lifecycle handoff', () => { }); }); + // AC5 applied to this route too. Without it the global assistant happily starts a + // SECOND generation on the same conversation — two agents, two assistant rows, two + // bills — which is exactly what the guard on POST /api/ai/chat prevents. The gap was + // real: this route calls createStreamLifecycle and had no in-flight guard at all. + describe('per-conversation takeover guard', () => { + it('given a new stream, should take over any in-flight stream on this conversation BEFORE creating the lifecycle', async () => { + await POST(makeRequest(), makeContext()); + + expect(mockTakeOverConversationStreams).toHaveBeenCalledTimes(1); + // Exact values. Both are known constants here, so expect.any(String) would have accepted a + // route that SWAPPED them — and a takeover keyed on the wrong conversation aborts the wrong + // streams, or none, while a second generation starts beside the first. + expect(mockTakeOverConversationStreams).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId: 'conv-1', + channelId: 'user:user-1:global', + }), + ); + expect(mockTakeOverConversationStreams.mock.invocationCallOrder[0]) + .toBeLessThan(mockCreateStreamLifecycle.mock.invocationCallOrder[0]); + }); + }); + describe('createStreamLifecycle invocation', () => { it('given a new global stream, should construct the lifecycle with channelId user:${userId}:global and the request browserSessionId', async () => { await POST(makeRequest({ browserSessionId: 'session-y' }), makeContext()); @@ -421,6 +456,9 @@ describe('POST /api/ai/global/[id]/messages — lifecycle handoff', () => { userId: 'user-1', displayName: 'Display User', browserSessionId: 'session-y', + // Rides the stream_start broadcast so page members can tell a stream they may + // watch from a co-member's PRIVATE conversation, without firing a doomed join. + isShared: false, }); }); diff --git a/apps/web/src/app/api/ai/global/[id]/messages/route.ts b/apps/web/src/app/api/ai/global/[id]/messages/route.ts index 3193ea54a3..d7a702fc2f 100644 --- a/apps/web/src/app/api/ai/global/[id]/messages/route.ts +++ b/apps/web/src/app/api/ai/global/[id]/messages/route.ts @@ -23,6 +23,7 @@ import type { SubscriptionTier } from '@pagespace/lib/services/subscription-util import { broadcastChatUserMessage } from '@/lib/websocket'; import { broadcastGlobalConversationAdded } from '@/lib/websocket/socket-utils'; import { createStreamLifecycle, type StreamLifecycleHandle } from '@/lib/ai/core/stream-lifecycle'; +import { takeOverConversationStreams } from '@/lib/ai/core/stream-takeover'; import { chunkToPart } from '@/lib/ai/streams/chunkToPart'; import { validateBrowserSessionIdHeader } from '@/lib/ai/core/browser-session-id-validation'; import { globalChannelId } from '@pagespace/lib/ai/global-channel-id'; @@ -1086,6 +1087,21 @@ MENTION PROCESSING: }).catch(() => {}); } + // Same per-conversation in-flight guard as POST /api/ai/chat. Without it the global + // assistant happily starts a second generation on the same conversation — two agents, two + // assistant rows, two bills. Takeover, not 409; see stream-liveness.ts for why rejecting + // would self-lock the conversation. + // + // KNOWN RACE — this does NOT guarantee "at most one generation per conversation", and this + // comment used to claim that it did. See the docblock on takeOverConversationStreams: the + // SELECT there and the INSERT in createStreamLifecycle are not atomic together, so two + // near-simultaneous sends can both find nothing in flight and both proceed. It narrows the + // window; it does not close it. + await takeOverConversationStreams({ + conversationId, + channelId, + }); + lifecycle = await createStreamLifecycle({ messageId: serverAssistantMessageId, channelId, @@ -1093,6 +1109,7 @@ MENTION PROCESSING: userId, displayName, browserSessionId, + isShared: conversation.isShared === true, }); // Outcome of the retry shell, shared from execute() to onFinish(). Carries the diff --git a/apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx b/apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx index 1ff035bf59..8564adbf55 100644 --- a/apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx +++ b/apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx @@ -8,6 +8,7 @@ import { TreePage } from '@/hooks/usePageTree'; import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'; +import { createId } from '@paralleldrive/cuid2'; import { useChat } from '@ai-sdk/react'; import { useParams } from 'next/navigation'; import { Button } from '@/components/ui/button'; @@ -31,7 +32,7 @@ import { abortActiveStream, abortActiveStreamByMessageId } from '@/lib/ai/core/s import { resolveActiveAssistantMessageId } from '@/lib/ai/streams/resolveActiveAssistantMessageId'; import { useAppStateRecovery } from '@/hooks/useAppStateRecovery'; import { isCapacitorApp } from '@/hooks/useCapacitor'; -import { isEditingActive } from '@/stores/useEditingStore'; +import { useEditingStore } from '@/stores/useEditingStore'; import { resolveResumeAction } from '@/lib/ai/streams/resolveResumeAction'; import { usePageSocketRoom } from '@/hooks/usePageSocketRoom'; import { useChannelStreamSocket } from '@/hooks/useChannelStreamSocket'; @@ -45,7 +46,6 @@ import { shouldReloadOnComountComplete } from '@/lib/ai/streams/shouldReloadOnCo import { getBrowserSessionId } from '@/lib/ai/core/browser-session-id'; import { shouldApplyLoadedMessages } from '@/lib/ai/streams/shouldApplyLoadedMessages'; import { mergeServerAndPending } from '@/lib/ai/streams/mergeServerAndPending'; -import { isPlaceholderConversationId } from '@/lib/ai/streams/isPlaceholderConversationId'; import { useShallow } from 'zustand/react/shallow'; // Shared hooks and components @@ -55,6 +55,7 @@ import { useProviderSettings, useConversations, useConversationIdentity, + type ConversationIdentityResolveResult, conversationIdFrom, isResolving, useChatTransport, @@ -148,6 +149,12 @@ const AiChatView: React.FC = ({ page }) => { // When set to a conversationId, the load-on-select effect skips the fetch for that // id on its next fire (messages are already provided inline, avoiding a double-fetch). const skipLoadEffectRef = useRef(null); + // Mirrors the identity hook's isPersisted for the callbacks that must read it + // outside of render (the message loader, the stream-completion handler). + const isPersistedRef = useRef(true); + // The identity hook is declared BELOW (it needs loadMessagesForConversation), so the + // loader reaches its setter through a ref rather than a closure. + const setPersistedRef = useRef<((isPersisted: boolean) => void) | null>(null); // Messages prefetched by resolveConversation (init path) for the id it resolves to, // consumed once by the load-on-select effect below to avoid a double-fetch. const preloadedMessagesRef = useRef<{ id: string; messages: UIMessage[] } | null>(null); @@ -161,7 +168,9 @@ const AiChatView: React.FC = ({ page }) => { // the transport on every switch caused useChat to reset its internal store, // which clobbered the messages written by loadMessagesForConversation — the // root cause of conversations not loading when clicked from History. - const transport = useChatTransport(page.id, '/api/ai/chat'); + // Third arg is the socket channel this page's streams are broadcast on — see + // useChatTransport / consumingChannels. + const transport = useChatTransport(page.id, '/api/ai/chat', page.id); const streamTrackingId = page.id; const handleChatError = useCallback((error: Error) => { @@ -196,8 +205,12 @@ const AiChatView: React.FC = ({ page }) => { conversationId: string, preloadedMessages?: UIMessage[], ): Promise => { - // Skip the placeholder id — it has no server-side messages yet. - if (isPlaceholderConversationId(conversationId, page.id)) return; + // Skip an id that has no server-side conversation yet (freshly minted, no + // message sent). Gated on the FACT, not on the shape of the id string — the + // old sentinel-string check kept refusing to load conversations the server + // had in fact persisted under that very id, which is why chats came back + // empty after a reload. + if (conversationId === currentConversationIdRef.current && !isPersistedRef.current) return; loadRequestedIdRef.current = conversationId; setIsLoadingMessages(true); @@ -215,6 +228,18 @@ const AiChatView: React.FC = ({ page }) => { ); // Stale check after await — user may have switched conversation. if (!shouldApplyLoadedMessages(conversationId, loadRequestedIdRef.current)) return; + // The conversation isn't there. Either the send that was supposed to create it + // never reached the server (the credit gate runs BEFORE the row is persisted, + // so a 402 leaves nothing behind), or it was deleted elsewhere. Fall back to + // "fresh chat" rather than showing a load-failure banner for a conversation + // that does not exist — this is the only thing that walks `isPersisted` back, + // and without it the flag is a one-way door on an unverified assumption. + if (res.status === 404) { + if (conversationId === currentConversationIdRef.current) { + setPersistedRef.current?.(false); + } + return; + } if (!res.ok) throw new Error(`Failed to load messages (${res.status})`); const data = await res.json(); if (!shouldApplyLoadedMessages(conversationId, loadRequestedIdRef.current)) return; @@ -228,7 +253,14 @@ const AiChatView: React.FC = ({ page }) => { // NOTE: prod runs multiple web instances — live tokens from a stream on another // instance won't be in the pending store; the persisted message still shows up // on the next DB load. Cross-instance live-token rejoin is a known follow-up. - const ownStream = usePendingStreamsStore.getState().getOwnStreams(page.id)[0]; + // Scoped by conversation, not `[0]`. getOwnStreams filters by pageId only, and a + // user can genuinely have two own streams on one page (send, then hit New Chat + // while it is still running) — in which case `[0]` may be the OTHER conversation's + // and the in-flight bubble for this one gets dropped on a DB reload. + const ownStream = usePendingStreamsStore + .getState() + .getOwnStreams(page.id) + .find((s) => s.conversationId === conversationId); const merged = ownStream?.conversationId === conversationId && ownStream.messageId ? mergeServerAndPending(serverMessages, ownStream.parts, ownStream.messageId, ownStream.startedAt) @@ -257,7 +289,7 @@ const AiChatView: React.FC = ({ page }) => { // fresh, disconnected conversation replacing a real one after a transient // network blip). Creating/selecting a conversation elsewhere is always a // synchronous setIdentity call — never routed through this resolver. - const resolveConversation = useCallback(async (): Promise<{ conversationId: string }> => { + const resolveConversation = useCallback(async (): Promise => { const listResponse = await fetchWithAuth( `/api/ai/page-agents/${page.id}/conversations?pageSize=1` ); @@ -288,23 +320,35 @@ const AiChatView: React.FC = ({ page }) => { if (loaded !== undefined) { preloadedMessagesRef.current = { id: conv.id, messages: loaded }; } - return { conversationId: conv.id }; + // Includes legacy `${pageId}-default` rows: the server used to accept that + // sentinel and mint a real conversation under it. They come back from the + // list like any other conversation and now load normally — which is how + // existing users get their stranded history back, with no data migration. + return { conversationId: conv.id, isPersisted: true }; } - // No persisted conversations exist yet. Derive a stable ID from the page so - // concurrent openers share the same conversation before either sends a message. - // The conversation is anchored in the DB once the first message is saved. - return { conversationId: `${page.id}-default` }; + // No conversation exists yet. Mint a real cuid — the server creates the row + // under exactly this id on the first send. (This used to be a `${pageId}-default` + // sentinel, which the server accepted unvalidated and the client then refused + // to load back: messages persisted, chat rendered empty.) + return { conversationId: createId(), isPersisted: false }; }, [page.id]); const { state: identityState, canSend: canSendMessage, + isPersisted, setIdentity, + setPersisted, retry: retryResolveConversation, } = useConversationIdentity({ resolve: resolveConversation }); const currentConversationId = conversationIdFrom(identityState); + // Same render-body-assignment rationale as currentConversationIdRef below: the + // loaders and the stream-completion callback must read the value from the most + // recently rendered pass, with no effect-flush lag. + isPersistedRef.current = isPersisted; + setPersistedRef.current = setPersisted; // Updated directly during render (not via an effect like pageIdRef) so the // late-joiner sync callback always reads the value from the most recently // rendered pass with no effect-flush lag. This component uses no @@ -387,7 +431,12 @@ const AiChatView: React.FC = ({ page }) => { setActiveTab('chat'); }, onConversationDelete: () => { - setIdentity(`${page.id}-default`); + // Mint a fresh cuid, not a sentinel. skipLoadEffectRef FIRST — otherwise the + // load-on-select effect fires for an id with nothing behind it and + // setMessages([]) lands on top of whatever the user does next. + const nextId = createId(); + skipLoadEffectRef.current = nextId; + setIdentity(nextId, { isPersisted: false }); setMessages([]); }, }); @@ -431,12 +480,12 @@ const AiChatView: React.FC = ({ page }) => { const syncedConversationRef = useRef(null); useEffect(() => { if (!currentConversationId) return; - if (isPlaceholderConversationId(currentConversationId, page.id)) return; + if (!isPersisted) return; if (conversations.some((c) => c.id === currentConversationId)) return; if (syncedConversationRef.current === currentConversationId) return; syncedConversationRef.current = currentConversationId; refreshConversations(); - }, [currentConversationId, conversations, page.id, refreshConversations]); + }, [currentConversationId, conversations, isPersisted, refreshConversations]); // Find in page const findQuery = useFindStore((s) => s.query); @@ -547,7 +596,7 @@ const AiChatView: React.FC = ({ page }) => { // here now — one authoritative fetch path for every conversation switch. useEffect(() => { if (!currentConversationId) return; - if (isPlaceholderConversationId(currentConversationId, page.id)) return; + if (!isPersisted) return; if (skipLoadEffectRef.current === currentConversationId) { skipLoadEffectRef.current = null; // consume the skip token return; @@ -559,7 +608,7 @@ const AiChatView: React.FC = ({ page }) => { return; } void loadMessagesForConversation(currentConversationId); - }, [currentConversationId, page.id, loadMessagesForConversation]); + }, [currentConversationId, isPersisted, loadMessagesForConversation]); // Register streaming state with editing store useStreamingRegistration( @@ -568,14 +617,45 @@ const AiChatView: React.FC = ({ page }) => { { pageId: page.id, componentName: 'AiChatView' } ); + // Conversation-scoped, mirroring selectChannelRemoteStreams. A page channel carries + // every conversation's streams; without the filter, a stream running in a DIFFERENT + // conversation on this page renders into the one on screen — which on its own looks + // exactly like duplication. + // + // The scoping is UNCONDITIONAL, including while the conversation is still unpersisted. + // + // It is tempting to drop the filter for a not-yet-sent conversation ("it owns no + // streams, so there is nothing to confuse it with") in order to restore the one + // deliberate property of the old `${pageId}-default` sentinel: two people opening a + // fresh AI page shared an id, so each could watch the other's stream. Do not. That + // property WAS a privacy leak. Conversations are private by default, and the page + // channel carries every conversation on the page — so on a shared AI page, an + // unfiltered surface renders another user's PRIVATE conversation token by token to + // anyone who opens the page. (The client-side filter is not the only defence any + // more — see the conversation-scoped authorization on /active-streams and + // /stream-join — but it must not be the hole either.) + // + // It also mistargets Stop: hitting "New Chat" mid-stream would leave the blank chat + // showing `effectiveIsStreaming` with a Stop button wired to the OLD conversation. + // + // A surface that never sent anything simply doesn't render someone else's stream. If + // that stream turns out to be this user's own conversation, onStreamComplete's + // late-joiner sync adopts it and the message appears. const remoteStreams = usePendingStreamsStore( - useShallow((state) => state.getRemotePageStreams(page.id)) + useShallow((state) => + currentConversationId === null + ? [] + : state.getRemotePageStreams(page.id).filter((s) => s.conversationId === currentConversationId) + ) ); // Subscribe to a primitive (messageId | undefined) so token appends to the // own stream don't churn this hook's identity and re-render ChatLayout per chunk. const ownStreamMessageId = usePendingStreamsStore( - (state) => state.getOwnStreams(page.id)[0]?.messageId + (state) => + currentConversationId === null + ? undefined + : state.getOwnStreams(page.id).find((s) => s.conversationId === currentConversationId)?.messageId ); const effectiveIsStreaming = isStreaming || ownStreamMessageId !== undefined; @@ -592,19 +672,32 @@ const AiChatView: React.FC = ({ page }) => { // SSE join via the resulting chat:stream_complete broadcast. const messageId = resolveActiveAssistantMessageId({ ownStreamMessageId, - isStreaming, + // 'streaming', NOT the looser isStreaming (which includes 'submitted'). useChat sets + // status='submitted' BEFORE issuing the request and pushes the new assistant message only + // inside write(), which flips to 'streaming' in the same job. So during submitted the + // array's last assistant message is THE PREVIOUS TURN'S reply — and passing the loose flag + // made this resolve to it and `return` early, aborting a message that finished minutes ago + // and never reaching the chatId fallback below, which would actually have worked. The local + // fetch stopped, the button looked like it worked, and the server kept generating and + // billing. Reachable on any 2nd+ turn, in the 0.5-3s window where a user hits Stop after a + // typo — the single most likely moment for them to hit it. + isStreaming: status === 'streaming', lastAssistantMessageId, }); if (messageId) { void abortActiveStreamByMessageId({ messageId }); return; } - // No assistant id yet (submitted, before first chunk): fall back to the chatId map. - // The transport's chatId is always page.id (Chat never recreates on conversation switch), - // so try streamTrackingId first (desired key) then page.id (actual registration key). - if (streamTrackingId) void abortActiveStream({ chatId: streamTrackingId }); - if (streamTrackingId !== page.id) void abortActiveStream({ chatId: page.id }); - }, [chatStop, ownStreamMessageId, isStreaming, lastAssistantMessageId, streamTrackingId, page.id]); + // No assistant id yet (submitted, before the first chunk). The chatId map is EMPTY here, not + // stale: setActiveStreamId only runs once the response headers land, and a real send spends + // 0.5-3s before that. So pass the conversationId — the one name we hold from t=0 — and the + // abort falls back to it. Without that, Stop in this window was a guaranteed no-op: the fetch + // was cancelled, the button flipped back to Send, and the server (which deliberately survives + // client disconnect) kept generating, kept running write tools, and kept billing. + const conversationId = currentConversationIdRef.current; + if (streamTrackingId) void abortActiveStream({ chatId: streamTrackingId, conversationId }); + if (streamTrackingId !== page.id) void abortActiveStream({ chatId: page.id, conversationId }); + }, [chatStop, ownStreamMessageId, status, lastAssistantMessageId, streamTrackingId, page.id]); usePageSocketRoom(page.id); const { rejoinActiveStreams } = useChannelStreamSocket(page.id, { @@ -643,14 +736,45 @@ const AiChatView: React.FC = ({ page }) => { onStreamComplete: (messageId, completedConvId) => { const stream = usePendingStreamsStore.getState().streams.get(messageId); + // The conversation row is definitely on the server by now (the stream that just + // finished wrote to it). If the cached list still doesn't have it, the optimistic + // sync below raced the POST: adoptConversationAsPersisted() flips isPersisted the + // moment a send leaves, so the list-sync effect fired, fetched a list that did not + // yet contain the row, and latched syncedConversationRef to this id — which then + // blocks it from ever trying again. Without this, the header's share toggle and the + // History tab never learn about the conversation the user is sitting in. + // Scoped to the conversation on screen. chat:stream_complete is broadcast to the + // whole page room with no conversation filter, so without this we'd refetch the + // conversation list on every OTHER member's assistant message — and their private + // conversations can never appear in our list, so it would refetch forever. + if (completedConvId === currentConversationId + && !conversations.some((c) => c.id === completedConvId)) { + if (syncedConversationRef.current === completedConvId) { + syncedConversationRef.current = null; + } + refreshConversations(); + } + if (stream && stream.parts.length > 0 && stream.conversationId === currentConversationId) { - // Guard against a duplicate: useChat may already hold this message when the - // stream was consumed by both the POST stream and the multicast SSE join. - setMessages((prev) => - prev.some((m) => m.id === messageId) - ? prev - : [...prev, synthesizeAssistantMessage(messageId, stream.parts, stream.startedAt)], - ); + // REPLACE by id — do not skip. An existing message with this id is NOT proof we already + // have the content. + // + // The server names the assistant message (`generateId: () => serverAssistantMessageId`), + // so useChat's copy shares the stream's `messageId`. useChat does not roll back on error, + // so a mid-stream network drop leaves its HALF-STREAMED message in the array — and that + // is precisely when recovery rejoins the multicast and `stream.parts` accumulates the + // FULL reply. Skipping threw the complete version away and left the user with the + // truncated one, the real text stranded in the DB until they navigated away and back. + // + // Replacing is correct for the duplicate case too (same id = same message; stream.parts + // is the authoritative copy), so it subsumes the original de-dup intent. + const synthesized = synthesizeAssistantMessage(messageId, stream.parts, stream.startedAt); + setMessages((prev) => { + const i = prev.findIndex((m) => m.id === messageId); + return i === -1 + ? [...prev, synthesized] + : prev.map((m, j) => (j === i ? synthesized : m)); + }); return; } @@ -661,7 +785,10 @@ const AiChatView: React.FC = ({ page }) => { if (!stream || stream.parts.length === 0) return; - if (isPlaceholderConversationId(currentConversationId, page.id)) { + // The stream belongs to a conversation this surface hasn't confirmed exists + // server-side yet (first message on a brand-new chat). Confirm it landed, then + // adopt it as persisted so the loaders stop skipping it. + if (!isPersistedRef.current) { const { parts, conversationId: streamConvId, startedAt } = stream; fetchWithAuth(`/api/ai/page-agents/${page.id}/conversations?pageSize=1`) .then(async (res) => { @@ -674,8 +801,8 @@ const AiChatView: React.FC = ({ page }) => { // create/select, it must not clobber an identity the user has // since moved on to (e.g. via New Chat or history-select) while // this fetch was in flight. Only apply if still on the same - // placeholder that triggered it. - if (!isPlaceholderConversationId(currentConversationIdRef.current, page.id)) return; + // unpersisted conversation that triggered it. + if (isPersistedRef.current) return; setIdentity(persisted.id); setMessages((prev) => prev.some((m) => m.id === messageId) @@ -801,6 +928,18 @@ const AiChatView: React.FC = ({ page }) => { buildBody: buildAskUserAnswerBody, }); + // A send creates the conversations row server-side under exactly this id, so the id + // becomes real the moment the POST leaves. Flipping isPersisted re-runs the + // load-on-select effect for the SAME id, though — and that effect would fetch a + // conversation whose first message has not been written yet and setMessages([]) over + // the optimistic user bubble and the in-flight stream. Claim the skip token first. + const adoptConversationAsPersisted = useCallback(() => { + if (isPersistedRef.current) return; + const id = currentConversationIdRef.current; + if (id) skipLoadEffectRef.current = id; + setPersisted(true); + }, [setPersisted]); + const handleSendMessage = useCallback(() => { if (isReadOnly) { toast.error('You do not have permission to send messages in this AI chat'); @@ -819,6 +958,8 @@ const AiChatView: React.FC = ({ page }) => { clearFiles(); inputRef.current?.clear(); + adoptConversationAsPersisted(); + wrapSend(async () => { const pageContext = await contextPromise; sendMessage( @@ -841,7 +982,9 @@ const AiChatView: React.FC = ({ page }) => { }, [ isReadOnly, input, - attachments.length, + // `attachments.length` was here and is redundant: getFilesForSend (below) is memoized on + // [attachments], so it already changes whenever they do. ESLint flagged it once the rule was + // promoted to an error for these files — which is the rule doing exactly its job. currentConversationId, canSendMessage, buildFreshPageContext, @@ -856,6 +999,7 @@ const AiChatView: React.FC = ({ page }) => { imageGenEnabled, mcpToolSchemas, wrapSend, + adoptConversationAsPersisted, ]); // Voice mode: Send message from voice transcript @@ -868,6 +1012,7 @@ const AiChatView: React.FC = ({ page }) => { if (!canSendMessage) return; const contextPromise = buildFreshPageContext(); + adoptConversationAsPersisted(); wrapSend(async () => { const pageContext = await contextPromise; sendMessage( @@ -900,6 +1045,7 @@ const AiChatView: React.FC = ({ page }) => { imageGenEnabled, mcpToolSchemas, wrapSend, + adoptConversationAsPersisted, ]); // Voice mode toggle handler @@ -922,6 +1068,11 @@ const AiChatView: React.FC = ({ page }) => { await loadMessagesForConversation(currentConversationId); }, [currentConversationId, loadMessagesForConversation]); + const resumeEnabled = useCallback( + () => currentConversationIdRef.current !== null && !useEditingStore.getState().isAnyEditing(), + [], + ); + // App state recovery - re-attach/refetch AI stream when returning from background. // On native (Capacitor): if streaming, stop the local fetch, rejoin any still-live // server stream, then refetch to recover a stream that finished while backgrounded. @@ -942,7 +1093,13 @@ const AiChatView: React.FC = ({ page }) => { } await handlePullUpRefresh(); }, [effectiveIsStreaming, chatStop, rejoinActiveStreams, handlePullUpRefresh]), - enabled: currentConversationId !== null && !isEditingActive(), + // Gate on USER editing only, and evaluate it at fire time (callback form). + // The old gate was `!isEditingActive()`, i.e. isAnyActive(), which is true + // whenever an 'ai-streaming' session exists — and this component registers one + // while streaming. So the hook early-returned in exactly the case it was + // written for. Worse, a boolean is captured at render, and iOS freezes JS + // while backgrounded, so the captured value was always the streaming one. + enabled: resumeEnabled, }); // Clean up stream tracking when conversation changes or on unmount @@ -960,6 +1117,15 @@ const AiChatView: React.FC = ({ page }) => { }; }, [streamTrackingId]); + // NOTE: deliberately NO unmarkChannelConsuming() on unmount. The consuming refcount + // is owned by the transport's response-body wrapper — one release per POST, and + // this component holds no reference of its own to release. Unmarking here would + // decrement someone else's count (a co-mounted surface on the same channel), and + // unmounting does not stop the body anyway: `useChat` keys its Chat instance by + // `page.id`, so a remount reuses the same instance and keeps consuming. The count + // clears itself when that body finishes — and a reload, which is the case this whole + // mechanism exists for, resets the module outright. + // ============================================ // RENDER // ============================================ diff --git a/apps/web/src/components/layout/middle-content/page-views/ai-page/__tests__/AiChatView.test.tsx b/apps/web/src/components/layout/middle-content/page-views/ai-page/__tests__/AiChatView.test.tsx index 81aa409c41..5a21dddda9 100644 --- a/apps/web/src/components/layout/middle-content/page-views/ai-page/__tests__/AiChatView.test.tsx +++ b/apps/web/src/components/layout/middle-content/page-views/ai-page/__tests__/AiChatView.test.tsx @@ -1,12 +1,14 @@ import { describe, test, vi, beforeEach, type Mock } from 'vitest'; +import { isCuid } from '@paralleldrive/cuid2'; import { render, act, waitFor, screen, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { assert } from './riteway'; // Hoisted mock instances accessible inside vi.mock factories -const { mockFetchWithAuth, mockSetMessages, mockLocalStop, mockAbortByMessageId } = vi.hoisted(() => ({ +const { mockFetchWithAuth, mockSetMessages, mockSendMessage, mockLocalStop, mockAbortByMessageId } = vi.hoisted(() => ({ mockFetchWithAuth: vi.fn(), mockSetMessages: vi.fn(), + mockSendMessage: vi.fn(), mockLocalStop: vi.fn(), mockAbortByMessageId: vi.fn(), })); @@ -25,7 +27,7 @@ vi.mock('next/navigation', () => ({ vi.mock('@ai-sdk/react', () => { const chatState = { messages: [] as unknown[], - sendMessage: vi.fn(), + sendMessage: mockSendMessage, status: 'idle' as const, error: undefined as Error | undefined, regenerate: vi.fn(), @@ -280,8 +282,16 @@ const latestMcpConversationId = (): string | null => { // fires inside that window, isPlaceholderConversationId() is false, the // late-joiner branch is skipped, and the sync fetch never starts — under CI // load this window is wide enough to hit. Wait for the resolved identity. -const waitForPlaceholderIdentity = (pageId: string) => - waitFor(() => expect(latestMcpConversationId()).toBe(`${pageId}-default`)); +// A page with no conversations yet resolves to a freshly minted cuid — NOT the old +// `${pageId}-default` sentinel, which the server accepted unvalidated and wrote real +// conversation rows under, and which both client load paths then hard-bailed on (so +// the messages persisted and were never loaded back). +const waitForUnpersistedIdentity = (pageId: string) => + waitFor(() => { + const id = latestMcpConversationId(); + expect(id).not.toBe(`${pageId}-default`); + expect(id !== null && isCuid(id)).toBe(true); + }); const PAGE_ID = 'page-123'; const CONV_ID = 'conv-existing-abc'; @@ -338,6 +348,201 @@ const wasPostCalled = (url: string) => callUrl === url && opts?.method === 'POST' ); +// AC3 step 1 — the migration-free recovery path. +// +// The client used to send a `${pageId}-default` sentinel as the conversation id for a +// brand-new chat. The server accepted it unvalidated and minted a REAL conversations row +// under that id, stamping every message to it. Both client load paths then hard-bailed on +// that exact string, and loadMessagesForConversation was the only setMessages writer — so +// the messages were persisted and then never loaded. Every such page rendered an empty +// chat after any reload. +// +// Those rows exist in production. Identity now carries `isPersisted` instead of pattern- +// matching the id, so a sentinel conversation coming back from the list loads like any +// other one. That is what gives existing users their history back, with no data migration. +describe('AiChatView — legacy `${pageId}-default` conversation (no migration)', () => { + const page = makePage(); + const LEGACY_CONV_ID = `${PAGE_ID}-default`; + const LEGACY_MESSAGES_URL = `${CONVERSATIONS_URL}/${LEGACY_CONV_ID}/messages`; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const strandedMessages = [ + { id: 'm1', role: 'user', parts: [{ type: 'text', text: 'what did we decide?' }] }, + { id: 'm2', role: 'assistant', parts: [{ type: 'text', text: 'the stranded reply' }] }, + ]; + + const setupLegacyConversation = () => { + mockFetchWithAuth.mockImplementation(async (url: string, opts?: { method?: string }) => { + if (url === PERMISSIONS_URL) return makeOkResponse({ canEdit: true }); + if (url === AGENT_CONFIG_URL) return makeOkResponse({}); + if (url === `${CONVERSATIONS_URL}?pageSize=1` && !opts?.method) { + return makeOkResponse({ conversations: [{ id: LEGACY_CONV_ID, title: 'Legacy', preview: '' }] }); + } + if (url === LEGACY_MESSAGES_URL && !opts?.method) { + return makeOkResponse({ messages: strandedMessages }); + } + return makeErrorResponse(); + }); + }; + + test('given a persisted conversation whose id is the old `${pageId}-default` sentinel, should fetch its messages instead of bailing on the id string', async () => { + setupLegacyConversation(); + render(); + + await waitFor(() => { + assert({ + given: 'a persisted conversation whose id happens to be the legacy sentinel', + should: 'fetch its messages (the old code hard-bailed on this exact string)', + actual: wasGetCalled(LEGACY_MESSAGES_URL), + expected: true, + }); + }); + }); + + test('given the legacy conversation loads, should write its stranded history into the chat', async () => { + setupLegacyConversation(); + render(); + + await waitFor(() => { + assert({ + given: 'a legacy sentinel conversation with persisted messages', + should: 'setMessages with the recovered history', + actual: mockSetMessages.mock.calls.some( + (args) => Array.isArray(args[0]) && (args[0] as Array<{ id: string }>).some((m) => m.id === 'm2'), + ), + expected: true, + }); + }); + }); + + test('given the legacy conversation is active, should adopt it as the conversation identity (so a send continues it rather than forking a new one)', async () => { + setupLegacyConversation(); + render(); + + await waitFor(() => { + assert({ + given: 'a legacy sentinel conversation returned by the list', + should: 'use it as the active conversation id', + actual: latestMcpConversationId(), + expected: LEGACY_CONV_ID, + }); + }); + }); +}); + +// AC3 step 2 + the hazard it creates. +// +// A brand-new chat resolves to a freshly minted cuid that has no server-side +// conversation yet, so the loaders skip it. The first send creates that conversation +// row under exactly this id — the id becomes real, and the loaders must stop skipping +// it. But flipping that flag re-runs the load-on-select effect for the SAME id, and if +// it were allowed to fetch, it would pull a conversation whose first message has not +// been written yet and setMessages([]) straight over the optimistic user bubble and the +// in-flight stream. Sending must claim the skip token before flipping. +describe('AiChatView — first send on a freshly minted conversation', () => { + const page = makePage(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const setupNoConversations = () => { + mockFetchWithAuth.mockImplementation(async (url: string, opts?: { method?: string }) => { + if (url === PERMISSIONS_URL) return makeOkResponse({ canEdit: true }); + if (url === AGENT_CONFIG_URL) return makeOkResponse({}); + if (url === `${CONVERSATIONS_URL}?pageSize=1` && !opts?.method) { + return makeOkResponse({ conversations: [] }); + } + // The freshly-minted conversation exists but has no messages yet — the exact + // window a stray load-on-select fetch would blank the chat in. + if (url.includes('/messages') && !opts?.method) return makeOkResponse({ messages: [] }); + return makeErrorResponse(); + }); + }; + + const lastChatLayoutProps = () => { + const calls = (ChatLayout as unknown as Mock).mock.calls; + return calls[calls.length - 1]?.[0] as { onSend: () => void; input: string } | undefined; + }; + + test('given the first send on a freshly minted conversation, should NOT fetch messages for it (a stray load would blank the optimistic bubble)', async () => { + setupNoConversations(); + render(); + + await waitForUnpersistedIdentity(PAGE_ID); + const mintedId = latestMcpConversationId(); + + // Type something, then send. + act(() => { + (ChatLayout as unknown as Mock).mock.calls[ + (ChatLayout as unknown as Mock).mock.calls.length - 1 + ][0].onInputChange?.('hello'); + }); + act(() => { + lastChatLayoutProps()?.onSend(); + }); + + await waitFor(() => { + assert({ + given: 'a first send on a freshly minted conversation', + should: 'hand the message to useChat', + actual: mockSendMessage.mock.calls.length > 0, + expected: true, + }); + }); + + assert({ + given: 'the conversation flipping to persisted on send', + should: 'not fetch that conversation\'s messages (the skip token was claimed first)', + actual: wasGetCalled(`${CONVERSATIONS_URL}/${mintedId}/messages`), + expected: false, + }); + }); + + test('given the first send, should never blank the messages array', async () => { + setupNoConversations(); + render(); + + await waitForUnpersistedIdentity(PAGE_ID); + + const blanksBefore = mockSetMessages.mock.calls.filter( + (args) => Array.isArray(args[0]) && (args[0] as unknown[]).length === 0, + ).length; + + act(() => { + (ChatLayout as unknown as Mock).mock.calls[ + (ChatLayout as unknown as Mock).mock.calls.length - 1 + ][0].onInputChange?.('hello'); + }); + act(() => { + lastChatLayoutProps()?.onSend(); + }); + + await waitFor(() => { + assert({ + given: 'a first send', + should: 'hand the message to useChat', + actual: mockSendMessage.mock.calls.length > 0, + expected: true, + }); + }); + + const blanksAfter = mockSetMessages.mock.calls.filter( + (args) => Array.isArray(args[0]) && (args[0] as unknown[]).length === 0, + ).length; + + assert({ + given: 'the conversation flipping to persisted mid-send', + should: 'never call setMessages([]) over the optimistic user bubble', + actual: blanksAfter, + expected: blanksBefore, + }); + }); +}); + describe('AiChatView initializeChat', () => { const page = makePage(); @@ -863,7 +1068,7 @@ describe('AiChatView late-joiner conversation sync', () => { expected: true, }); }); - await waitForPlaceholderIdentity(PAGE_ID); + await waitForUnpersistedIdentity(PAGE_ID); (usePendingStreamsStore as unknown as { getState: Mock }).getState.mockReturnValue({ streams: new Map([[MESSAGE_ID, { parts: [{ type: 'text', text: 'AI response' }], conversationId: REAL_CONV_ID }]]), @@ -906,7 +1111,7 @@ describe('AiChatView late-joiner conversation sync', () => { expected: true, }); }); - await waitForPlaceholderIdentity(PAGE_ID); + await waitForUnpersistedIdentity(PAGE_ID); // The page-scoped placeholder id must not trigger a refresh (nothing persisted yet). assert({ @@ -957,7 +1162,7 @@ describe('AiChatView late-joiner conversation sync', () => { expected: true, }); }); - await waitForPlaceholderIdentity(PAGE_ID); + await waitForUnpersistedIdentity(PAGE_ID); (usePendingStreamsStore as unknown as { getState: Mock }).getState.mockReturnValue({ streams: new Map([[MESSAGE_ID, { parts: [{ type: 'text', text: 'AI response' }], conversationId: REAL_CONV_ID }]]), @@ -1001,7 +1206,7 @@ describe('AiChatView late-joiner conversation sync', () => { expected: true, }); }); - await waitForPlaceholderIdentity(PAGE_ID); + await waitForUnpersistedIdentity(PAGE_ID); (usePendingStreamsStore as unknown as { getState: Mock }).getState.mockReturnValue({ streams: new Map([[MESSAGE_ID, { parts: [{ type: 'text', text: 'AI response' }], conversationId: REAL_CONV_ID }]]), @@ -1043,7 +1248,7 @@ describe('AiChatView late-joiner conversation sync', () => { expected: true, }); }); - await waitForPlaceholderIdentity(PAGE_ID); + await waitForUnpersistedIdentity(PAGE_ID); (usePendingStreamsStore as unknown as { getState: Mock }).getState.mockReturnValue({ streams: new Map([[MESSAGE_ID, { parts: [{ type: 'text', text: 'AI response' }], conversationId: REAL_CONV_ID }]]), @@ -1086,7 +1291,7 @@ describe('AiChatView late-joiner conversation sync', () => { expected: true, }); }); - await waitForPlaceholderIdentity(PAGE_ID); + await waitForUnpersistedIdentity(PAGE_ID); (usePendingStreamsStore as unknown as { getState: Mock }).getState.mockReturnValue({ streams: new Map([[MESSAGE_ID, { parts: [{ type: 'text', text: 'AI response' }], conversationId: REAL_CONV_ID }]]), @@ -1159,7 +1364,7 @@ describe('AiChatView late-joiner conversation sync', () => { expected: true, }); }); - await waitForPlaceholderIdentity(PAGE_ID); + await waitForUnpersistedIdentity(PAGE_ID); (usePendingStreamsStore as unknown as { getState: Mock }).getState.mockReturnValue({ streams: new Map([[MESSAGE_ID, { parts: [{ type: 'text', text: 'AI from page A' }], conversationId: REAL_CONV_ID }]]), @@ -1209,7 +1414,7 @@ describe('AiChatView late-joiner conversation sync', () => { expected: true, }); }); - await waitForPlaceholderIdentity(PAGE_ID); + await waitForUnpersistedIdentity(PAGE_ID); (usePendingStreamsStore as unknown as { getState: Mock }).getState.mockReturnValue({ streams: new Map([[MESSAGE_ID, { parts: [{ type: 'text', text: 'AI response' }], conversationId: REAL_CONV_ID }]]), @@ -1396,7 +1601,7 @@ describe('AiChatView stop button for reconnected own streams', () => { type StoreState = { streams: Map; getRemotePageStreams: (pageId: string) => unknown[]; - getOwnStreams: (pageId: string) => Array<{ messageId: string; pageId: string; isOwn: true }>; + getOwnStreams: (pageId: string) => Array<{ messageId: string; pageId: string; isOwn: true; conversationId: string }>; }; const setStoreSelectors = ({ @@ -1404,7 +1609,7 @@ describe('AiChatView stop button for reconnected own streams', () => { own = [], }: { remote?: unknown[]; - own?: Array<{ messageId: string; pageId: string; isOwn: true }>; + own?: Array<{ messageId: string; pageId: string; isOwn: true; conversationId: string }>; }) => { const state: StoreState = { streams: new Map(), @@ -1449,10 +1654,125 @@ describe('AiChatView stop button for reconnected own streams', () => { setStoreSelectors({ remote: [], own: [] }); }); + // PRIVACY. It is tempting to drop the conversation filter while the conversation is + // still unpersisted ("a fresh chat owns no streams, so there is nothing to confuse it + // with"), in order to restore the one deliberate property of the old + // `${pageId}-default` sentinel: two openers of a fresh page shared an id, so each + // could watch the other's stream. That property WAS a leak. Conversations are private + // by default and the page channel carries all of them, so an unfiltered surface + // renders another member's PRIVATE conversation to anyone who opens the page. + test('given a NOT-YET-PERSISTED conversation, another user\'s stream on this page must NOT render (it may be their private conversation)', async () => { + mockFetchWithAuth.mockImplementation(async (url: string, opts?: { method?: string }) => { + if (url === PERMISSIONS_URL) return makeOkResponse({ canEdit: true }); + if (url === AGENT_CONFIG_URL) return makeOkResponse({}); + if (url === `${CONVERSATIONS_URL}?pageSize=1` && !opts?.method) { + return makeOkResponse({ conversations: [] }); + } + return makeErrorResponse(); + }); + setStoreSelectors({ + remote: [{ + messageId: 'msg-someone-elses-private-stream', + pageId: PAGE_ID, + isOwn: false, + conversationId: 'their-private-conversation', + parts: [{ type: 'text', text: 'secret' }], + }], + }); + render(); + + await waitForUnpersistedIdentity(PAGE_ID); + + assert({ + given: 'a fresh, unpersisted conversation and another user streaming on the same page', + should: 'render none of their stream', + actual: lastChatLayoutProps()?.remoteStreams, + expected: [], + }); + }); + + // The same unscoping would also mistarget Stop: hitting "New Chat" mid-stream leaves an + // own stream in the OLD conversation, and an unscoped selector would light up the blank + // chat's Stop button pointing at it. + test('given a NOT-YET-PERSISTED conversation and an own stream still running in the OLD one, should not show this chat as streaming', async () => { + mockFetchWithAuth.mockImplementation(async (url: string, opts?: { method?: string }) => { + if (url === PERMISSIONS_URL) return makeOkResponse({ canEdit: true }); + if (url === AGENT_CONFIG_URL) return makeOkResponse({}); + if (url === `${CONVERSATIONS_URL}?pageSize=1` && !opts?.method) { + return makeOkResponse({ conversations: [] }); + } + return makeErrorResponse(); + }); + setStoreSelectors({ + own: [{ messageId: 'msg-old-conv', pageId: PAGE_ID, isOwn: true, conversationId: 'the-previous-conversation' }], + }); + render(); + + await waitForUnpersistedIdentity(PAGE_ID); + + assert({ + given: 'an own stream still running in a different (previous) conversation', + should: 'not light up isStreaming for the blank chat (its Stop would abort the wrong stream)', + actual: lastChatLayoutProps()?.isStreaming, + expected: false, + }); + }); + + // AC6. A page channel carries every conversation's streams. Without a conversation + // filter, a stream running in a DIFFERENT conversation on this page renders into the + // one on screen — which on its own looks exactly like duplication. + test('given an own stream in a DIFFERENT conversation on this page, ChatLayout must NOT treat it as this conversation streaming', async () => { + setupHappyInit(); + setStoreSelectors({ + own: [{ messageId: 'msg-other-conv', pageId: PAGE_ID, isOwn: true, conversationId: 'some-other-conversation' }], + }); + render(); + + await waitFor(() => { + assert({ + given: 'init with an existing conversation', + should: 'load conversation messages', + actual: wasGetCalled(MESSAGES_URL), + expected: true, + }); + }); + + assert({ + given: 'a pending own stream belonging to another conversation on the same page', + should: 'not light up isStreaming for the conversation on screen', + actual: lastChatLayoutProps()?.isStreaming, + expected: false, + }); + }); + + test('given a remote stream in a DIFFERENT conversation on this page, ChatLayout should not receive it', async () => { + setupHappyInit(); + setStoreSelectors({ + remote: [{ messageId: 'msg-remote-other', pageId: PAGE_ID, isOwn: false, conversationId: 'some-other-conversation', parts: [] }], + }); + render(); + + await waitFor(() => { + assert({ + given: 'init with an existing conversation', + should: 'load conversation messages', + actual: wasGetCalled(MESSAGES_URL), + expected: true, + }); + }); + + assert({ + given: 'a remote stream belonging to another conversation on the same page', + should: 'not render it into the conversation on screen', + actual: lastChatLayoutProps()?.remoteStreams, + expected: [], + }); + }); + test('given an own stream is pending and useChat status is idle, ChatLayout receives isStreaming=true (so stop button renders after refresh)', async () => { setupHappyInit(); setStoreSelectors({ - own: [{ messageId: 'msg-own-1', pageId: PAGE_ID, isOwn: true }], + own: [{ messageId: 'msg-own-1', pageId: PAGE_ID, isOwn: true, conversationId: CONV_ID }], }); render(); @@ -1485,7 +1805,7 @@ describe('AiChatView stop button for reconnected own streams', () => { test('given useChat status is idle but an own stream is pending, calling ChatLayout.onStop calls abortActiveStreamByMessageId with the own stream messageId', async () => { setupHappyInit(); setStoreSelectors({ - own: [{ messageId: 'msg-own-7', pageId: PAGE_ID, isOwn: true }], + own: [{ messageId: 'msg-own-7', pageId: PAGE_ID, isOwn: true, conversationId: CONV_ID }], }); render(); @@ -1521,7 +1841,7 @@ describe('AiChatView stop button for reconnected own streams', () => { test('given useChat is actively streaming, calling ChatLayout.onStop stops the local fetch AND aborts the server stream by the stable messageId', async () => { setupHappyInit(); setStoreSelectors({ - own: [{ messageId: 'msg-own-2', pageId: PAGE_ID, isOwn: true }], + own: [{ messageId: 'msg-own-2', pageId: PAGE_ID, isOwn: true, conversationId: CONV_ID }], }); const { useChat } = await import('@ai-sdk/react'); const useChatMock = useChat as unknown as Mock; @@ -1574,7 +1894,7 @@ describe('AiChatView stop button for reconnected own streams', () => { test('given voice mode is active and an own stream is pending, VoiceCallPanel receives isAIStreaming=true and a stop handler that aborts by messageId', async () => { setupHappyInit(); setStoreSelectors({ - own: [{ messageId: 'msg-own-voice', pageId: PAGE_ID, isOwn: true }], + own: [{ messageId: 'msg-own-voice', pageId: PAGE_ID, isOwn: true, conversationId: CONV_ID }], }); vi.mocked(useVoiceModeStore).mockImplementation( ((selector: (state: { isEnabled: boolean; owner: string; enable: () => void; disable: () => void }) => unknown) => diff --git a/apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx b/apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx index bea71e7c45..e485f86775 100644 --- a/apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx +++ b/apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx @@ -286,7 +286,7 @@ const GlobalAssistantView: React.FC = () => { // CHAT CONFIGURATION // ============================================ - const agentTransport = useChatTransport(agentConversationId, '/api/ai/chat'); + const agentTransport = useChatTransport(agentConversationId, '/api/ai/chat', selectedAgent?.id ?? null); // Agent mode chat config const agentChatConfig = useMemo(() => { diff --git a/apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx b/apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx index 1ec5f8535f..4906b7e9d1 100644 --- a/apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx +++ b/apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx @@ -223,7 +223,7 @@ const SidebarChatTab: React.FC = () => { // ============================================ // Namespaced key prevents activeStreams collision when both panels view the same conversationId. const sidebarChatId = agentConversationId ? `sidebar:${agentConversationId}` : null; - const agentTransport = useChatTransport(sidebarChatId, '/api/ai/chat'); + const agentTransport = useChatTransport(sidebarChatId, '/api/ai/chat', selectedAgent?.id ?? null); const agentChatConfig = useMemo(() => { if (!selectedAgent || !agentConversationId || !agentTransport) return null; diff --git a/apps/web/src/contexts/GlobalChatContext.tsx b/apps/web/src/contexts/GlobalChatContext.tsx index 44a72a62ee..141a9d5fac 100644 --- a/apps/web/src/contexts/GlobalChatContext.tsx +++ b/apps/web/src/contexts/GlobalChatContext.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { createContext, useContext, ReactNode, useState, useReducer, useCallback, useEffect, useMemo, useRef } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; import { DefaultChatTransport, UIMessage } from 'ai'; import { fetchWithAuth } from '@/lib/auth/auth-fetch'; import { conversationState } from '@/lib/ai/core/conversation-state'; @@ -22,7 +23,8 @@ import { useSocketStore } from '@/stores/useSocketStore'; import { useAuth } from '@/hooks/useAuth'; import { useChannelStreamSocket } from '@/hooks/useChannelStreamSocket'; import type { ChatGlobalConversationAddedPayload } from '@/lib/websocket/socket-utils'; -import { abortActiveStreamByMessageId } from '@/lib/ai/core/stream-abort-client'; +import { abortActiveStreamByMessageId, clearActiveStreamId } from '@/lib/ai/core/stream-abort-client'; +import { shouldClaimGlobalStopSlot } from '@/lib/ai/streams/shouldClaimGlobalStopSlot'; import { globalChannelId } from '@pagespace/lib/ai/global-channel-id'; import { usePendingStreamsStore } from '@/stores/usePendingStreamsStore'; @@ -83,7 +85,15 @@ interface GlobalChatConfigContextValue { onError: (error: Error) => void; } | null; setIsStreaming: (streaming: boolean) => void; - setStopStreaming: (fn: (() => void) | null) => void; + /** + * The raw useState dispatch — so a FUNCTION argument is an UPDATER, not a value. + * + * Typed honestly as SetStateAction because the old signature lied: it invited callers to + * pass a stop fn directly, which React would then CALL as an updater — aborting the + * stream on the spot and storing `undefined`. To store a stop fn, wrap it: + * `setStopStreaming(() => stopFn)`. + */ + setStopStreaming: Dispatch void) | null>>; } // ============================================ @@ -159,7 +169,7 @@ export function GlobalChatProvider({ children }: { children: ReactNode }) { setInitialMessages([]); setIsMessagesLoading(false); } - }, []); + }, [dispatchIdentity]); const createNewConversation = useCallback(async () => { try { @@ -177,7 +187,7 @@ export function GlobalChatProvider({ children }: { children: ReactNode }) { } catch (error) { console.error('Failed to create new conversation:', error); } - }, []); + }, [dispatchIdentity]); useEffect(() => { const initializeGlobalChat = async () => { @@ -261,16 +271,55 @@ export function GlobalChatProvider({ children }: { children: ReactNode }) { // ============================================ const { user } = useAuth(); const userId = user?.id ?? null; - const channelId = userId ? globalChannelId(userId) : undefined; + const channelId = userId ? globalChannelId(userId) : null; const setIsStreamingRef = useRef(setIsStreaming); setIsStreamingRef.current = setIsStreaming; const setStopStreamingRef = useRef(setStopStreaming); setStopStreamingRef.current = setStopStreaming; + // The stop fn currently INSTALLED, and the one WE installed. `isStreaming`/`stopStreaming` + // is a single shared slot that GlobalAssistantView also writes directly from its local + // chat status, outside the claim protocol — so "I claimed for messageId M" does not mean + // the slot still holds my stop fn by the time M finalizes. Releasing on the messageId + // alone would kill a Stop button (and the streaming flag, and its SWR-clobber protection) + // belonging to a DIFFERENT, live stream. The takeover makes this deterministic: a new + // send aborts the bootstrapped stream M, whose chat:stream_complete then arrives while + // the new stream is the one on screen. + const currentStopStreamingRef = useRef<(() => void) | null>(stopStreaming); + currentStopStreamingRef.current = stopStreaming; + const ownedStopFnRef = useRef<(() => void) | null>(null); + // WHICH conversation the claim belongs to. The claim is deliberately allowed to land + // before this surface has resolved its identity (rejecting on a null id there would drop + // the very stream we are about to render) — but a claim made in ignorance must be + // re-examined once we know. Without this, a stream in conversation X could keep the Stop + // button lit for conversation Y. + const claimedConvIdRef = useRef(null); + + // A DIFFERENT fn installed means another, live stream owns the slot — leave both halves + // alone. A NULL slot means nobody owns it: it is free, and still ours to clear. + // (GlobalAssistantView nulls the stop fn on ordinary paths — its effect's else-branch and + // cleanup fire whenever the chat status is 'ready', which it is for the whole life of a + // BOOTSTRAPPED stream — without ever touching isStreaming. Reading that as "not ours" + // would strand isStreaming true and brick the composer.) + const releaseStopSlotIfStillOurs = () => { + if (claimedStopMessageIdRef.current === null) return; + const current = currentStopStreamingRef.current; + const stillOurs = current === ownedStopFnRef.current || current === null; + claimedStopMessageIdRef.current = null; + claimedConvIdRef.current = null; + ownedStopFnRef.current = null; + if (!stillOurs) return; + setIsStreamingRef.current(false); + setStopStreamingRef.current(null); + }; + const releaseStopSlotRef = useRef(releaseStopSlotIfStillOurs); + releaseStopSlotRef.current = releaseStopSlotIfStillOurs; const setRefreshSignalRef = useRef(setRefreshSignal); setRefreshSignalRef.current = setRefreshSignal; - const { rejoinActiveStreams: rejoinGlobalStream } = useChannelStreamSocket(channelId, { + const claimedStopMessageIdRef = useRef(null); + + const { rejoinActiveStreams: rejoinGlobalStream } = useChannelStreamSocket(channelId ?? undefined, { // Cross-tab same-user events: signal surfaces to re-fetch rather than // updating context state that nobody renders from. onUserMessage: (_message, payload) => { @@ -289,30 +338,81 @@ export function GlobalChatProvider({ children }: { children: ReactNode }) { if (!shouldRefreshAfterUndo(payload, currentConversationId, getBrowserSessionId())) return; setRefreshSignalRef.current((n) => n + 1); }, - onStreamComplete: (messageId) => { + onStreamComplete: (messageId, completedConvId, info) => { const stream = usePendingStreamsStore.getState().streams.get(messageId); // Remote or bootstrapped stream: signal surfaces to fetch the persisted message. - if (stream && stream.conversationId === currentConversationId) { + if (stream && stream.conversationId === currentConversationIdRef.current) { + setRefreshSignalRef.current((n) => n + 1); + return; + } + // The SSE join failed (the stream ran on another web instance), so its store entry + // was dropped and there is nothing here to render — but the message IS durably + // persisted. Without this we'd fall through to "our useChat already has it" and + // silently lose the reply. + if (info?.joinFailed && completedConvId === currentConversationIdRef.current) { setRefreshSignalRef.current((n) => n + 1); return; } // Own fresh stream: surface's useChat already has the message. }, - onOwnStreamBootstrap: ({ messageId }) => { - setIsStreamingRef.current(true); - setStopStreamingRef.current(() => () => { - abortActiveStreamByMessageId({ messageId }); + onOwnStreamBootstrap: ({ messageId, conversationId }) => { + // Conversation-scoped: an own stream in another conversation on this user's + // global channel must not light up the Stop button for the one on screen. + // Only reject a KNOWN mismatch — the DB bootstrap can land before the + // conversation identity has resolved, and rejecting on a null id there would + // drop the very stream this surface is about to render. + // Single-writer, and conversation-scoped. See shouldClaimGlobalStopSlot: the bootstrap sweep + // fires once per own in-flight stream, so two live own streams land here in one loop — and + // an unconditional claim let the second silently destroy the first. + const claimable = shouldClaimGlobalStopSlot({ + incomingMessageId: messageId, + incomingConversationId: conversationId, + heldMessageId: claimedStopMessageIdRef.current, + heldConversationId: claimedConvIdRef.current, + activeConversationId: currentConversationIdRef.current, }); + if (!claimable) return; + + const stopFn = () => { + abortActiveStreamByMessageId({ messageId }); + }; + claimedStopMessageIdRef.current = messageId; + claimedConvIdRef.current = conversationId; + ownedStopFnRef.current = stopFn; + setIsStreamingRef.current(true); + setStopStreamingRef.current(() => stopFn); + }, + // Same reconciliation as useAgentChannelMultiplayer: a claim released only by + // onOwnStreamFinalize strands forever on the paths where that event cannot fire (a + // socket-instance swap tears the effect down without finalizing). Bootstrap is the + // server's word on what is still running. + onActiveStreamsSnapshot: (liveMessageIds) => { + const claimed = claimedStopMessageIdRef.current; + if (claimed === null || liveMessageIds.has(claimed)) return; + releaseStopSlotRef.current(); }, - onOwnStreamFinalize: () => { - setIsStreamingRef.current(false); - setStopStreamingRef.current(null); + onOwnStreamFinalize: ({ messageId }) => { + // Only the stream that actually claimed the Stop control may release it. + if (claimedStopMessageIdRef.current !== messageId) return; + releaseStopSlotRef.current(); }, onGlobalConversationAdded: (payload) => { setLatestGlobalConversationAdded(payload); }, }); + // A claim made before identity resolved is a claim made in ignorance. Once we know which + // conversation this surface is actually showing, a claim that names a DIFFERENT one is not + // ours to hold: it would keep the Stop button lit, and the composer disabled, for a stream + // the user is not looking at. Re-examine it the moment the answer arrives. + useEffect(() => { + const claimedConvId = claimedConvIdRef.current; + if (claimedConvId === null) return; + if (currentConversationId === null) return; // still unknown — keep holding + if (claimedConvId === currentConversationId) return; + releaseStopSlotRef.current(); + }, [currentConversationId]); + const prevConversationIdRef = useRef(null); useEffect(() => { if (currentConversationId !== prevConversationIdRef.current && currentConversationId) { @@ -321,7 +421,18 @@ export function GlobalChatProvider({ children }: { children: ReactNode }) { }, [currentConversationId]); const apiEndpoint = currentConversationId ? `/api/ai/global/${currentConversationId}/messages` : ''; - const transport = useChatTransport(currentConversationId, apiEndpoint); + const transport = useChatTransport(currentConversationId, apiEndpoint, channelId); + + // This context REGISTERS `currentConversationId` in the activeStreams map (the transport above), + // and until now nothing ever freed it — GlobalAssistantView's unmount cleanup was clearing this + // key, but that surface does not own it, and it is gone the moment you navigate off the + // dashboard while the context lives on. So: the owner frees its own key, and only its own. + useEffect(() => { + if (!currentConversationId) return; + return () => { + clearActiveStreamId({ chatId: currentConversationId }); + }; + }, [currentConversationId]); const chatConfig = useMemo(() => { if (!currentConversationId || !transport) return null; @@ -341,7 +452,7 @@ export function GlobalChatProvider({ children }: { children: ReactNode }) { // same synchronous IDENTITY_SET path as loadConversation/createNewConversation. const setCurrentConversationId = useCallback((id: string | null) => { if (id) dispatchIdentity({ type: 'IDENTITY_SET', conversationId: id }); - }, []); + }, [dispatchIdentity]); // ============================================ // Context Values @@ -359,6 +470,7 @@ export function GlobalChatProvider({ children }: { children: ReactNode }) { rejoinGlobalStream, latestGlobalConversationAdded, }), [ + setCurrentConversationId, currentConversationId, initialMessages, isInitialized, diff --git a/apps/web/src/contexts/__tests__/GlobalChatContext.test.tsx b/apps/web/src/contexts/__tests__/GlobalChatContext.test.tsx index 7825db6339..931971f541 100644 --- a/apps/web/src/contexts/__tests__/GlobalChatContext.test.tsx +++ b/apps/web/src/contexts/__tests__/GlobalChatContext.test.tsx @@ -29,6 +29,7 @@ const { mockClearPageStreams, mockConsumeStreamJoin, mockAbortActiveStreamByMessageId, + mockClearActiveStreamId, mockGetBrowserSessionId, } = vi.hoisted(() => { const handlers: Record void)[]> = {}; @@ -62,6 +63,7 @@ const { mockClearPageStreams: vi.fn(), mockConsumeStreamJoin: vi.fn().mockResolvedValue(undefined), mockAbortActiveStreamByMessageId: vi.fn().mockResolvedValue({ aborted: true, reason: '' }), + mockClearActiveStreamId: vi.fn(), mockGetBrowserSessionId: vi.fn(() => SESSION_ID_LOCAL), }; }); @@ -96,6 +98,7 @@ vi.mock('@/lib/ai/core/stream-join-client', () => ({ vi.mock('@/lib/ai/core/stream-abort-client', () => ({ abortActiveStreamByMessageId: mockAbortActiveStreamByMessageId, + clearActiveStreamId: mockClearActiveStreamId, })); vi.mock('@/lib/ai/core/browser-session-id', () => ({ @@ -133,8 +136,9 @@ vi.mock('@/lib/ai/shared', async (importOriginal) => ({ useStreamingRegistration: vi.fn(), })); -import { GlobalChatProvider, useGlobalChatConversation, useGlobalChatStream } from '../GlobalChatContext'; +import { GlobalChatProvider, useGlobalChatConversation, useGlobalChatStream, useGlobalChatConfig } from '../GlobalChatContext'; import { useStreamingRegistration } from '@/lib/ai/shared'; +import { markChannelConsuming, resetConsumingChannels } from '@/lib/ai/streams/consumingChannels'; // --- Helpers --- @@ -166,6 +170,8 @@ describe('GlobalChatProvider — socket reconnect refresh', () => { vi.clearAllMocks(); mockSocket._reset(); mockStreams.clear(); + // Module state — a real reload clears it; a test file must too. + resetConsumingChannels(); mockUseSocketStore.mockImplementation((selector: (s: { connectionStatus: string }) => unknown) => selector({ connectionStatus: mockConnectionStatus }) ); @@ -316,6 +322,8 @@ describe('GlobalChatProvider — conversation identity race guards', () => { vi.clearAllMocks(); mockSocket._reset(); mockStreams.clear(); + // Module state — a real reload clears it; a test file must too. + resetConsumingChannels(); mockUseSocketStore.mockImplementation((selector: (s: { connectionStatus: string }) => unknown) => selector({ connectionStatus: 'disconnected' }) ); @@ -452,6 +460,8 @@ describe('GlobalChatProvider — global channel stream socket', () => { vi.clearAllMocks(); mockSocket._reset(); mockStreams.clear(); + // Module state — a real reload clears it; a test file must too. + resetConsumingChannels(); mockUseSocketStore.mockImplementation((selector: (s: { connectionStatus: string }) => unknown) => selector({ connectionStatus: 'connected' }) ); @@ -489,6 +499,213 @@ describe('GlobalChatProvider — global channel stream socket', () => { }); }); + // The SSE join can fail benignly — the stream lives on another web instance, whose + // in-process multicast registry we cannot reach. It finished fine and the reply IS + // durably persisted; the store entry was dropped because whatever parts we held were a + // stale snapshot. Keying purely on "is there still a store entry?" would fall through + // to "our useChat already has it" and silently lose the reply. + it('given the SSE join failed and the stream completes for the active conversation, should refresh to load the persisted reply', async () => { + const { result } = renderProvider(); + await waitFor(() => expect(mockSocket._handlerCount('chat:stream_complete')).toBeGreaterThan(0)); + await waitFor(() => expect(result.current.currentConversationId).toBe(CONV_ID)); + + const before = result.current.refreshSignal; + + // A remote stream arrives, and the SSE join rejects — the stream lives on another + // web instance whose multicast registry we cannot reach. + let rejectJoin!: (err: Error) => void; + mockConsumeStreamJoin.mockReturnValueOnce(new Promise((_res, rej) => { rejectJoin = rej; })); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + act(() => { + mockSocket._trigger('chat:stream_start', { + messageId: 'msg-join-failed', + pageId: GLOBAL_CHANNEL_ID, + conversationId: CONV_ID, + triggeredBy: { userId: 'user-other', displayName: 'Alice', browserSessionId: SESSION_ID_REMOTE }, + }); + }); + await act(async () => { rejectJoin(new Error('404 — other instance')); await Promise.resolve(); }); + + // The stream finished server-side; the reply is persisted. + act(() => { + mockSocket._trigger('chat:stream_complete', { + messageId: 'msg-join-failed', + pageId: GLOBAL_CHANNEL_ID, + conversationId: CONV_ID, + }); + }); + + await waitFor(() => expect(result.current.refreshSignal).toBe(before + 1)); + errorSpy.mockRestore(); + }); + + // BUG SEVEN, from the other side. GlobalAssistantView is the one writer that never joined + // the claim protocol: its stop-registration effects nulled the shared slot + // UNCONDITIONALLY, on an else-branch and a cleanup that both fire whenever the chat + // status is 'ready' — which it is for the ENTIRE life of a bootstrapped stream, and whose + // deps resolve asynchronously right after the claim BY DESIGN. So it destroyed a live + // Stop button belonging to the stream socket, leaving isStreaming:true with + // stopStreaming:null — Stop renders and does nothing while the stream keeps billing. + // + // This test guards the contract from the context side: once a bootstrap claim is in the + // slot, a foreign `setStopStreaming(null)` is the caller's bug — but the claim itself + // must survive as a working Stop until the stream actually ends. + it('given a bootstrapped claim, the installed stop fn must remain callable and abort the stream it named', async () => { + mockFetchWithAuth.mockImplementation((url: string) => { + if (url.includes('/api/ai/chat/active-streams')) { + return okResponse({ + streams: [{ + messageId: 'msg-claimed', + conversationId: CONV_ID, + triggeredBy: { userId: USER_ID, displayName: 'Me', browserSessionId: SESSION_ID_LOCAL }, + }], + }); + } + return defaultFetch(url); + }); + mockConsumeStreamJoin.mockReturnValueOnce(new Promise(() => {})); + + const { result } = renderHook(() => useGlobalChatStream(), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + + const stop = result.current.stopStreaming; + expect(typeof stop).toBe('function'); + + // It must ACT, not merely return another function (the updater/value trap). + const returned = stop!() as unknown; + + expect(mockAbortActiveStreamByMessageId).toHaveBeenCalledWith({ messageId: 'msg-claimed' }); + expect(typeof returned).not.toBe('function'); + }); + + // The bootstrap claim is deliberately allowed to land BEFORE this surface has resolved its + // conversation — rejecting on a null id there would drop the very stream we are about to + // render. But a claim made in ignorance must be re-examined once the answer arrives: if it + // names a DIFFERENT conversation, holding it would keep the Stop button lit and the + // composer disabled for a stream the user is not looking at. + it('given a claim landed before identity resolved, and identity resolves to a DIFFERENT conversation, the claim should be released', async () => { + mockFetchWithAuth.mockImplementation((url: string) => { + if (url.includes('/api/ai/chat/active-streams')) { + return okResponse({ + streams: [{ + messageId: 'msg-other-conv', + conversationId: 'a-different-conversation', + triggeredBy: { userId: USER_ID, displayName: 'Me', browserSessionId: SESSION_ID_LOCAL }, + }], + }); + } + // Identity resolves to CONV_ID — NOT the stream's conversation. + return defaultFetch(url); + }); + mockConsumeStreamJoin.mockReturnValueOnce(new Promise(() => {})); + + const { result } = renderHook( + () => ({ conv: useGlobalChatConversation(), stream: useGlobalChatStream() }), + { wrapper: Wrapper }, + ); + + await waitFor(() => expect(result.current.conv.currentConversationId).toBe(CONV_ID)); + + // The claim named another conversation, so it must not be holding our Stop. + await waitFor(() => expect(result.current.stream.isStreaming).toBe(false)); + expect(result.current.stream.stopStreaming).toBeNull(); + }); + + // A NULL slot is free, not foreign. GlobalAssistantView nulls the stop fn on ordinary + // paths (its effect's else-branch and cleanup fire whenever globalStatus is 'ready' — + // which it is for the entire life of a BOOTSTRAPPED stream) without ever touching + // isStreaming. Treating that as "not ours" skips setIsStreaming(false) and strands it + // true forever: ChatInput then renders Stop instead of Send, disables the textarea, and + // the Stop is a no-op. The Global Assistant composer is bricked until a full reload. + it('given the stop fn was nulled by another surface, finalizing must still clear isStreaming', async () => { + mockFetchWithAuth.mockImplementation((url: string) => { + if (url.includes('/api/ai/chat/active-streams')) { + return okResponse({ + streams: [{ + messageId: 'msg-boot', + conversationId: CONV_ID, + triggeredBy: { userId: USER_ID, displayName: 'Me', browserSessionId: SESSION_ID_LOCAL }, + }], + }); + } + return defaultFetch(url); + }); + mockConsumeStreamJoin.mockReturnValueOnce(new Promise(() => {})); + + const { result } = renderHook( + () => ({ stream: useGlobalChatStream(), config: useGlobalChatConfig() }), + { wrapper: Wrapper }, + ); + + await waitFor(() => expect(result.current.stream.isStreaming).toBe(true)); + + // GlobalAssistantView's effect cleanup nulls the stop fn — it never touches isStreaming. + act(() => { result.current.config.setStopStreaming(null); }); + await waitFor(() => expect(result.current.stream.stopStreaming).toBeNull()); + + act(() => { + mockSocket._trigger('chat:stream_complete', { + messageId: 'msg-boot', + pageId: GLOBAL_CHANNEL_ID, + conversationId: CONV_ID, + }); + }); + + await waitFor(() => expect(result.current.stream.isStreaming).toBe(false)); + }); + + // The isStreaming/stopStreaming pair is a single shared slot that GlobalAssistantView + // also writes directly from its local chat status, outside the claim protocol. The + // takeover makes the clobber DETERMINISTIC: a reloaded tab bootstraps own stream M and + // claims the slot; the user sends again; the server takes over and aborts M; M's + // chat:stream_complete then arrives — and releasing on the messageId alone would kill + // the Stop button and streaming flag of the NEW, live stream (and with them its + // SWR-clobber protection), with no effect left to restore them. + it('given the slot was taken over by a newer stream, finalizing the OLD claimed stream must not clear it', async () => { + mockFetchWithAuth.mockImplementation((url: string) => { + if (url.includes('/api/ai/chat/active-streams')) { + return okResponse({ + streams: [{ + messageId: 'msg-old', + conversationId: CONV_ID, + triggeredBy: { userId: USER_ID, displayName: 'Me', browserSessionId: SESSION_ID_LOCAL }, + }], + }); + } + return defaultFetch(url); + }); + mockConsumeStreamJoin.mockReturnValueOnce(new Promise(() => {})); + + const { result } = renderHook( + () => ({ stream: useGlobalChatStream(), config: useGlobalChatConfig() }), + { wrapper: Wrapper }, + ); + + // Bootstrap claims the slot for the old stream. + await waitFor(() => expect(result.current.stream.isStreaming).toBe(true)); + const claimedStop = result.current.stream.stopStreaming; + expect(typeof claimedStop).toBe('function'); + + // A newer stream takes the slot over (this is what GlobalAssistantView does directly). + const newerStop = vi.fn(); + act(() => { result.current.config.setStopStreaming(() => newerStop); }); + await waitFor(() => expect(result.current.stream.stopStreaming).toBe(newerStop)); + + // The server's takeover aborts the old stream; its completion now arrives. + act(() => { + mockSocket._trigger('chat:stream_complete', { + messageId: 'msg-old', + pageId: GLOBAL_CHANNEL_ID, + conversationId: CONV_ID, + }); + }); + + expect(result.current.stream.stopStreaming).toBe(newerStop); + expect(result.current.stream.isStreaming).toBe(true); + }); + // AC2 — own bootstrap stream it('given a bootstrapped own stream, should addStream isOwn=true and surface stop via context', async () => { mockFetchWithAuth.mockImplementation((url: string) => { @@ -496,7 +713,7 @@ describe('GlobalChatProvider — global channel stream socket', () => { return okResponse({ streams: [{ messageId: 'msg-own', - conversationId: 'conv-own', + conversationId: CONV_ID, triggeredBy: { userId: USER_ID, displayName: 'Me', browserSessionId: SESSION_ID_LOCAL }, }], }); @@ -598,7 +815,8 @@ describe('GlobalChatProvider — global channel stream socket', () => { }); // AC5 — own-tab live event filtered - it('given chat:stream_start from the own browser session, should ignore the event', async () => { + it('given chat:stream_start from the own browser session while this context is CONSUMING the POST body, should ignore the event', async () => { + markChannelConsuming(GLOBAL_CHANNEL_ID); renderProvider(); await waitFor(() => expect(mockSocket._handlerCount('chat:stream_start')).toBeGreaterThan(0)); @@ -616,6 +834,29 @@ describe('GlobalChatProvider — global channel stream socket', () => { expect(mockConsumeStreamJoin).not.toHaveBeenCalledWith('msg-self', expect.anything(), expect.anything()); }); + // The reload case: browserSessionId lives in sessionStorage and survives a reload, + // but the consuming set is module state and does not. A reloaded tab must attach to + // the stream it started rather than dropping it forever. + it('given chat:stream_start from the own browser session while NOT consuming (i.e. after a reload), should attach and reclaim the Stop button', async () => { + renderProvider(); + + await waitFor(() => expect(mockSocket._handlerCount('chat:stream_start')).toBeGreaterThan(0)); + + act(() => { + mockSocket._trigger('chat:stream_start', { + messageId: 'msg-self', + pageId: GLOBAL_CHANNEL_ID, + conversationId: 'conv-1', + triggeredBy: { userId: USER_ID, displayName: 'Me', browserSessionId: SESSION_ID_LOCAL }, + }); + }); + + expect(mockAddStream).toHaveBeenCalledWith( + expect.objectContaining({ messageId: 'msg-self', isOwn: true }), + ); + expect(mockConsumeStreamJoin).toHaveBeenCalledWith('msg-self', expect.anything(), expect.anything()); + }); + // AC6 — channel filter it('given chat:stream_start with a different channel id, should ignore the event', async () => { renderProvider(); @@ -757,7 +998,7 @@ describe('GlobalChatProvider — global channel stream socket', () => { return okResponse({ streams: [{ messageId: 'msg-own', - conversationId: 'conv-own', + conversationId: CONV_ID, triggeredBy: { userId: USER_ID, displayName: 'Me', browserSessionId: SESSION_ID_LOCAL }, }], }); @@ -795,7 +1036,7 @@ describe('GlobalChatProvider — global channel stream socket', () => { return okResponse({ streams: [{ messageId: 'msg-own', - conversationId: 'conv-own', + conversationId: CONV_ID, triggeredBy: { userId: USER_ID, displayName: 'Me', browserSessionId: SESSION_ID_LOCAL }, }], }); @@ -905,6 +1146,8 @@ describe('GlobalChatProvider — editing-store registration', () => { vi.clearAllMocks(); mockSocket._reset(); mockStreams.clear(); + // Module state — a real reload clears it; a test file must too. + resetConsumingChannels(); mockUseSocketStore.mockImplementation((selector: (s: { connectionStatus: string }) => unknown) => selector({ connectionStatus: 'connected' }) ); diff --git a/apps/web/src/hooks/__tests__/useAgentChannelMultiplayer.test.ts b/apps/web/src/hooks/__tests__/useAgentChannelMultiplayer.test.ts index 6eecb60e9a..d1de9a38c1 100644 --- a/apps/web/src/hooks/__tests__/useAgentChannelMultiplayer.test.ts +++ b/apps/web/src/hooks/__tests__/useAgentChannelMultiplayer.test.ts @@ -130,7 +130,7 @@ describe('useAgentChannelMultiplayer', () => { })); act(() => { - capturedChannel.options?.onOwnStreamBootstrap?.({ messageId: 'msg-own' }); + capturedChannel.options?.onOwnStreamBootstrap?.({ messageId: 'msg-own', conversationId: 'conv-1' }); }); const state = usePageAgentDashboardStore.getState(); @@ -147,7 +147,7 @@ describe('useAgentChannelMultiplayer', () => { })); act(() => { - capturedChannel.options?.onOwnStreamBootstrap?.({ messageId: 'msg-own' }); + capturedChannel.options?.onOwnStreamBootstrap?.({ messageId: 'msg-own', conversationId: 'conv-1' }); }); expect(usePageAgentDashboardStore.getState().agentStopStreaming).toBe(existingStop); @@ -161,7 +161,7 @@ describe('useAgentChannelMultiplayer', () => { })); act(() => { - capturedChannel.options?.onOwnStreamBootstrap?.({ messageId: 'msg-own' }); + capturedChannel.options?.onOwnStreamBootstrap?.({ messageId: 'msg-own', conversationId: 'conv-1' }); }); act(() => { capturedChannel.options?.onOwnStreamFinalize?.({ messageId: 'msg-own' }); @@ -182,7 +182,7 @@ describe('useAgentChannelMultiplayer', () => { // Bootstrap fires but slot is occupied; this surface declines to claim. act(() => { - capturedChannel.options?.onOwnStreamBootstrap?.({ messageId: 'msg-own' }); + capturedChannel.options?.onOwnStreamBootstrap?.({ messageId: 'msg-own', conversationId: 'conv-1' }); }); // Finalize arrives — must NOT clear the other surface's slot. act(() => { @@ -200,7 +200,7 @@ describe('useAgentChannelMultiplayer', () => { })); act(() => { - capturedChannel.options?.onOwnStreamBootstrap?.({ messageId: 'msg-own' }); + capturedChannel.options?.onOwnStreamBootstrap?.({ messageId: 'msg-own', conversationId: 'conv-1' }); }); const stop = usePageAgentDashboardStore.getState().agentStopStreaming; @@ -501,7 +501,7 @@ describe('useAgentChannelMultiplayer', () => { // Bootstrap claims the slot. act(() => { - capturedChannel.options?.onOwnStreamBootstrap?.({ messageId: 'msg-own' }); + capturedChannel.options?.onOwnStreamBootstrap?.({ messageId: 'msg-own', conversationId: 'conv-1' }); }); expect(typeof usePageAgentDashboardStore.getState().agentStopStreaming).toBe('function'); @@ -525,7 +525,7 @@ describe('useAgentChannelMultiplayer', () => { // Bootstrap fires but slot is occupied — this surface declines. act(() => { - capturedChannel.options?.onOwnStreamBootstrap?.({ messageId: 'msg-own' }); + capturedChannel.options?.onOwnStreamBootstrap?.({ messageId: 'msg-own', conversationId: 'conv-1' }); }); // Surface unmounts. The other writer's slot must survive. unmount(); diff --git a/apps/web/src/hooks/__tests__/useAppStateRecovery.test.ts b/apps/web/src/hooks/__tests__/useAppStateRecovery.test.ts new file mode 100644 index 0000000000..d65079af10 --- /dev/null +++ b/apps/web/src/hooks/__tests__/useAppStateRecovery.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +vi.mock('@/hooks/useCapacitor', () => ({ + isCapacitorApp: () => false, +})); + +import { useAppStateRecovery } from '../useAppStateRecovery'; + +const BACKGROUND_MS = 10_000; + +/** Drive the web visibilitychange path: hide, wait out minBackgroundTime, show. */ +const backgroundThenResume = async (backgroundedForMs = BACKGROUND_MS) => { + const realNow = Date.now(); + const nowSpy = vi.spyOn(Date, 'now'); + + nowSpy.mockReturnValue(realNow); + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + await act(async () => { document.dispatchEvent(new Event('visibilitychange')); }); + + nowSpy.mockReturnValue(realNow + backgroundedForMs); + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }); + await act(async () => { document.dispatchEvent(new Event('visibilitychange')); await Promise.resolve(); }); + + nowSpy.mockRestore(); +}; + +describe('useAppStateRecovery', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('given enabled=true, should call onResume after a long-enough background', async () => { + const onResume = vi.fn(); + renderHook(() => useAppStateRecovery({ onResume, enabled: true })); + + await backgroundThenResume(); + + expect(onResume).toHaveBeenCalledTimes(1); + }); + + it('given enabled=false, should not call onResume', async () => { + const onResume = vi.fn(); + renderHook(() => useAppStateRecovery({ onResume, enabled: false })); + + await backgroundThenResume(); + + expect(onResume).not.toHaveBeenCalled(); + }); + + it('given a background shorter than minBackgroundTime, should not call onResume', async () => { + const onResume = vi.fn(); + renderHook(() => useAppStateRecovery({ onResume, enabled: true, minBackgroundTime: 5000 })); + + await backgroundThenResume(1000); + + expect(onResume).not.toHaveBeenCalled(); + }); + + // AC4. This is the whole point of the callback form. A boolean `enabled` is captured + // at RENDER — and iOS freezes JS the moment the app backgrounds, so the value that + // ends up gating the resume is whatever was true when the app went away. On the AI + // page that value was "a stream is running", i.e. the hook disabled itself in exactly + // the case it was written for. + describe('enabled as a callback — evaluated at fire time, not captured at render', () => { + it('given a callback that flips false->true while backgrounded (no re-render), should call onResume', async () => { + const onResume = vi.fn(); + let allowed = false; + + renderHook(() => useAppStateRecovery({ onResume, enabled: () => allowed })); + + // The gate is false at render time. No re-render happens after this point — + // exactly what a frozen, backgrounded WebView gives you. + allowed = true; + + await backgroundThenResume(); + + expect(onResume).toHaveBeenCalledTimes(1); + }); + + it('given a callback that flips true->false while backgrounded, should NOT call onResume', async () => { + const onResume = vi.fn(); + let allowed = true; + + renderHook(() => useAppStateRecovery({ onResume, enabled: () => allowed })); + + allowed = false; + + await backgroundThenResume(); + + expect(onResume).not.toHaveBeenCalled(); + }); + + // Contrast: proves the boolean form really does capture at render, which is why the + // AI page must pass a callback. + it('given a BOOLEAN enabled that goes stale with no re-render, should use the stale value', async () => { + const onResume = vi.fn(); + let allowed = false; + + const { rerender } = renderHook(() => useAppStateRecovery({ onResume, enabled: allowed })); + rerender(); + + allowed = true; // never re-rendered — the hook cannot see this + + await backgroundThenResume(); + + expect(onResume).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/hooks/__tests__/useChannelStreamSocket.test.ts b/apps/web/src/hooks/__tests__/useChannelStreamSocket.test.ts index 9f120d2861..a5f06b136d 100644 --- a/apps/web/src/hooks/__tests__/useChannelStreamSocket.test.ts +++ b/apps/web/src/hooks/__tests__/useChannelStreamSocket.test.ts @@ -65,6 +65,18 @@ vi.mock('@/hooks/useSocket', () => ({ useSocket: () => mockSocket, })); +const LOCAL_USER_ID = 'user-1'; +let mockAuthUserId: string | null = LOCAL_USER_ID; +vi.mock('@/hooks/useAuth', () => ({ + useAuth: () => ({ user: mockAuthUserId === null ? null : { id: mockAuthUserId } }), +})); + +let mockConnectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error' = 'disconnected'; +vi.mock('@/stores/useSocketStore', () => ({ + useSocketStore: (selector: (s: { connectionStatus: string }) => unknown) => + selector({ connectionStatus: mockConnectionStatus }), +})); + vi.mock('@/stores/usePendingStreamsStore', () => ({ usePendingStreamsStore: { getState: () => ({ @@ -95,6 +107,7 @@ vi.mock('@/lib/ai/streams/bootstrapConsumerGuard', () => ({ import { useChannelStreamSocket } from '../useChannelStreamSocket'; import { releaseBootstrapConsumer } from '@/lib/ai/streams/bootstrapConsumerGuard'; +import { markChannelConsuming, resetConsumingChannels } from '@/lib/ai/streams/consumingChannels'; import type { AiStreamStartPayload, AiStreamCompletePayload, @@ -175,6 +188,10 @@ describe('useChannelStreamSocket', () => { beforeEach(() => { vi.clearAllMocks(); mockSocket._reset(); + // Module state — a real reload clears it; a test file must too. + resetConsumingChannels(); + mockConnectionStatus = 'disconnected'; + mockAuthUserId = LOCAL_USER_ID; mockConsumeStreamJoin.mockResolvedValue({ aborted: false }); mockGetBrowserSessionId.mockReturnValue(SESSION_ID_LOCAL); mockFetchWithAuth.mockResolvedValue({ @@ -238,7 +255,7 @@ describe('useChannelStreamSocket', () => { await act(async () => { resolveJoin(); }); expect(mockRemoveStream).toHaveBeenCalledWith('msg-1'); - expect(onStreamComplete).toHaveBeenCalledWith('msg-1', 'conv-1'); + expect(onStreamComplete).toHaveBeenCalledWith('msg-1', 'conv-1', { joinFailed: false }); }); it('should call onStreamComplete before removeStream so stream data is available in the callback (SSE path)', async () => { @@ -273,12 +290,13 @@ describe('useChannelStreamSocket', () => { }); describe('chat:stream_start from local browser session', () => { - it('given triggeredBy.browserSessionId matches getBrowserSessionId(), should skip addStream and consumeStreamJoin', () => { + it('given an own-session stream this context is CONSUMING over the POST body, should skip addStream and consumeStreamJoin (no double-render)', () => { const localPayload: AiStreamStartPayload = { ...START_PAYLOAD, triggeredBy: { userId: 'user-1', displayName: 'Me', browserSessionId: SESSION_ID_LOCAL }, }; + markChannelConsuming('page-a'); renderHook(() => useChannelStreamSocket('page-a')); act(() => { mockSocket._trigger('chat:stream_start', localPayload); }); @@ -286,6 +304,54 @@ describe('useChannelStreamSocket', () => { expect(mockConsumeStreamJoin).not.toHaveBeenCalled(); }); + // THE HEADLINE REGRESSION TEST. A reload wipes the consuming set (module state) + // but NOT the browserSessionId (sessionStorage). Under the old blanket own-session + // skip, the reloaded tab still looked like the originator and dropped its own + // stream forever: no bubble, no Stop button, live input, while the server kept + // generating and editing pages. + it('given an own-session stream this context is NOT consuming (i.e. after a reload), should attach to it like any other subscriber', () => { + const localPayload: AiStreamStartPayload = { + ...START_PAYLOAD, + triggeredBy: { userId: 'user-1', displayName: 'Me', browserSessionId: SESSION_ID_LOCAL }, + }; + + // The consuming set is empty — exactly the state a freshly-loaded document is in. + renderHook(() => useChannelStreamSocket('page-a')); + act(() => { mockSocket._trigger('chat:stream_start', localPayload); }); + + expect(mockAddStream).toHaveBeenCalledWith(expect.objectContaining({ + messageId: 'msg-1', + isOwn: true, + })); + expect(mockConsumeStreamJoin).toHaveBeenCalledTimes(1); + }); + + it('given a REMOTE stream while this context is consuming its own on the same channel, should still attach (multiplayer is not collateral damage)', () => { + markChannelConsuming('page-a'); + renderHook(() => useChannelStreamSocket('page-a')); + act(() => { mockSocket._trigger('chat:stream_start', START_PAYLOAD); }); + + expect(mockAddStream).toHaveBeenCalledWith(expect.objectContaining({ + messageId: 'msg-1', + isOwn: false, + })); + expect(mockConsumeStreamJoin).toHaveBeenCalledTimes(1); + }); + + it('given an own-session stream after a reload, should fire onOwnStreamBootstrap so the Stop button comes back', () => { + const onOwnStreamBootstrap = vi.fn(); + renderHook(() => useChannelStreamSocket('page-a', { onOwnStreamBootstrap })); + + act(() => { + mockSocket._trigger('chat:stream_start', { + ...START_PAYLOAD, + triggeredBy: { userId: 'user-1', displayName: 'Me', browserSessionId: SESSION_ID_LOCAL }, + }); + }); + + expect(onOwnStreamBootstrap).toHaveBeenCalledWith({ messageId: 'msg-1', conversationId: 'conv-1' }); + }); + it('given triggeredBy.userId matches local user but browserSessionId differs, should still addStream (cross-session same-user)', () => { const otherSessionSameUser: AiStreamStartPayload = { ...START_PAYLOAD, @@ -303,6 +369,91 @@ describe('useChannelStreamSocket', () => { }); }); + // A page room holds every member of the page, but conversations are private by default + // (`listConversations` shows you only `userId = you OR isShared`). The server refuses + // these joins anyway; skipping them client-side is what stops every member firing a + // doomed /stream-join per assistant message — each one an authz denial in the audit log. + describe('chat:stream_start — conversation privacy pre-filter', () => { + it("given another user's stream in a PRIVATE conversation, should not attach or even attempt the join", () => { + renderHook(() => useChannelStreamSocket('page-a')); + + act(() => { + mockSocket._trigger('chat:stream_start', { + ...START_PAYLOAD, + isShared: false, + triggeredBy: { userId: 'user-other', displayName: 'Alice', browserSessionId: SESSION_ID_REMOTE }, + }); + }); + + expect(mockAddStream).not.toHaveBeenCalled(); + expect(mockConsumeStreamJoin).not.toHaveBeenCalled(); + }); + + it("given another user's stream in an explicitly SHARED conversation, should attach (multiplayer)", () => { + renderHook(() => useChannelStreamSocket('page-a')); + + act(() => { + mockSocket._trigger('chat:stream_start', { + ...START_PAYLOAD, + isShared: true, + triggeredBy: { userId: 'user-other', displayName: 'Alice', browserSessionId: SESSION_ID_REMOTE }, + }); + }); + + expect(mockAddStream).toHaveBeenCalled(); + expect(mockConsumeStreamJoin).toHaveBeenCalledTimes(1); + }); + + // The same user in a second tab: a different browserSessionId, so `isOwn` is false — + // but it is still THEIR stream and their private conversation. They must see it. + it('given MY OWN stream from another tab in a private conversation, should still attach', () => { + renderHook(() => useChannelStreamSocket('page-a')); + + act(() => { + mockSocket._trigger('chat:stream_start', { + ...START_PAYLOAD, + isShared: false, + triggeredBy: { userId: LOCAL_USER_ID, displayName: 'Me', browserSessionId: 'a-different-tab' }, + }); + }); + + expect(mockAddStream).toHaveBeenCalled(); + expect(mockConsumeStreamJoin).toHaveBeenCalledTimes(1); + }); + + // `useAuth()` returns `user: null` until auth resolves. Treating "I don't know who I + // am" as "not me" would drop the user's OWN private stream in that window — the exact + // failure this whole PR exists to fix. Skip only on certainty; when in doubt, attach + // and let the server decide (an unauthorized join is a quiet 404). + it('given the signed-in user is not known yet, should NOT drop a private stream (it may be the user\'s own)', () => { + mockAuthUserId = null; + renderHook(() => useChannelStreamSocket('page-a')); + + act(() => { + mockSocket._trigger('chat:stream_start', { + ...START_PAYLOAD, + isShared: false, + triggeredBy: { userId: LOCAL_USER_ID, displayName: 'Me', browserSessionId: 'another-tab' }, + }); + }); + + expect(mockAddStream).toHaveBeenCalled(); + expect(mockConsumeStreamJoin).toHaveBeenCalledTimes(1); + }); + + // Rolling deploy: an originator on the previous build emits no `isShared` field. + // Dropping on `undefined` would black out multiplayer for shared conversations + // mid-deploy, so only an explicit `false` skips. The server is the authority anyway. + it('given no isShared field at all (old server, rolling deploy), should attempt the join rather than drop it', () => { + renderHook(() => useChannelStreamSocket('page-a')); + + act(() => { mockSocket._trigger('chat:stream_start', START_PAYLOAD); }); + + expect(mockAddStream).toHaveBeenCalled(); + expect(mockConsumeStreamJoin).toHaveBeenCalledTimes(1); + }); + }); + // A1 — pageId guard describe('chat:user_message', () => { it('given a chat:user_message from another user for the current channel, should call onUserMessage with the message payload', () => { @@ -586,7 +737,7 @@ describe('useChannelStreamSocket', () => { act(() => { mockSocket._trigger('chat:stream_complete', COMPLETE_PAYLOAD); }); expect(mockRemoveStream).toHaveBeenCalledWith('msg-1'); - expect(onStreamComplete).toHaveBeenCalledWith('msg-1', 'conv-1'); + expect(onStreamComplete).toHaveBeenCalledWith('msg-1', 'conv-1', { joinFailed: false }); }); it('should call onStreamComplete before removeStream so stream data is available in the callback (socket path)', () => { @@ -655,7 +806,12 @@ describe('useChannelStreamSocket', () => { expect(onStreamComplete).toHaveBeenCalledTimes(1); }); - it('given SSE rejects then chat:stream_complete fires, should NOT call onStreamComplete', async () => { + // A failed SSE join does NOT mean the stream failed — the usual cause is that it + // is owned by another web instance, whose in-process multicast registry we can't + // reach. It keeps generating and it still broadcasts chat:stream_complete. Under + // the old behaviour that event was suppressed, so the ghost vanished and the + // finished (durably persisted) assistant message was never loaded. + it('given SSE rejects then chat:stream_complete fires, should call onStreamComplete with the store entry ALREADY removed, so the surface reloads from the DB', async () => { let rejectJoin!: (err: Error) => void; mockConsumeStreamJoin.mockReturnValueOnce( new Promise((_res, rej) => { rejectJoin = rej; }), @@ -668,9 +824,14 @@ describe('useChannelStreamSocket', () => { await act(async () => { rejectJoin(new Error('network down')); await Promise.resolve(); }); + mockRemoveStream.mockClear(); act(() => { mockSocket._trigger('chat:stream_complete', COMPLETE_PAYLOAD); }); - expect(onStreamComplete).not.toHaveBeenCalled(); + expect(onStreamComplete).toHaveBeenCalledWith('msg-1', 'conv-1', { joinFailed: true }); + // Removed BEFORE the callback fired: shouldReloadOnComountComplete keys on the + // absence of a store entry to choose the reload-from-DB branch over synthesizing + // a bubble from what is, at best, a stale snapshot. + expect(mockRemoveStream).toHaveBeenCalledWith('msg-1'); errorSpy.mockRestore(); }); @@ -728,7 +889,7 @@ describe('useChannelStreamSocket', () => { await act(async () => { resolveJoin(); }); expect(firstCallback).not.toHaveBeenCalled(); - expect(secondCallback).toHaveBeenCalledWith('msg-1', 'conv-1'); + expect(secondCallback).toHaveBeenCalledWith('msg-1', 'conv-1', { joinFailed: false }); }); }); @@ -1160,6 +1321,138 @@ describe('useChannelStreamSocket', () => { }); // AC6 — bootstrap unmount safety + // AC2. socket.io reconnects the SAME instance in place, so the main effect + // (deps [socket, channelId]) does not re-run — without this, every chat:stream_start + // emitted during an offline blip is lost and the tab stays blind to a live stream + // until it is reloaded. + // The reconciliation snapshot is what releases a consumer's Stop-slot claim when the + // stream it names has ended. It is authoritative, so it must be RIGHT: it may only speak + // on a successful, non-superseded response, and access_revoked — which latches + // `cancelled` and therefore silences every future bootstrap on this channel — must + // release the claims itself or they strand forever. + describe('onActiveStreamsSnapshot', () => { + it('given a successful bootstrap, should report the live messageIds', async () => { + const onActiveStreamsSnapshot = vi.fn(); + mockFetchWithAuth.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + streams: [{ + messageId: 'msg-live', + conversationId: 'conv-1', + triggeredBy: { userId: 'user-2', displayName: 'A', browserSessionId: SESSION_ID_REMOTE }, + }], + }), + }); + + renderHook(() => useChannelStreamSocket('page-a', { onActiveStreamsSnapshot })); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + + expect(onActiveStreamsSnapshot).toHaveBeenCalledWith(new Set(['msg-live'])); + }); + + it('given the bootstrap fetch fails, should NOT report a snapshot (never release a claim on no information)', async () => { + const onActiveStreamsSnapshot = vi.fn(); + mockFetchWithAuth.mockResolvedValueOnce({ ok: false, json: async () => ({}) }); + + renderHook(() => useChannelStreamSocket('page-a', { onActiveStreamsSnapshot })); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + + expect(onActiveStreamsSnapshot).not.toHaveBeenCalled(); + }); + + it('given the bootstrap throws, should NOT report a snapshot', async () => { + const onActiveStreamsSnapshot = vi.fn(); + mockFetchWithAuth.mockRejectedValueOnce(new Error('network down')); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + renderHook(() => useChannelStreamSocket('page-a', { onActiveStreamsSnapshot })); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + + expect(onActiveStreamsSnapshot).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + // access_revoked latches `cancelled` on this effect's closure, so every future + // runBootstrap — including the reconnect re-bootstrap and rejoinActiveStreams, which + // both call through bootstrapRef into THIS closure — returns early. The snapshot can + // never fire again on this channel, so a claim released only by it would strand + // forever: a Stop button over a stream the user can no longer even reach. + it('given access is revoked, should finalize every own stream so no claim strands', async () => { + const onOwnStreamFinalize = vi.fn(); + mockFetchWithAuth.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + streams: [{ + messageId: 'msg-own', + conversationId: 'conv-1', + triggeredBy: { userId: 'user-1', displayName: 'Me', browserSessionId: SESSION_ID_LOCAL }, + }], + }), + }); + + renderHook(() => useChannelStreamSocket('page-a', { onOwnStreamFinalize })); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + + act(() => { mockSocket._trigger('access_revoked', { room: 'page-a' }); }); + + expect(onOwnStreamFinalize).toHaveBeenCalledWith({ messageId: 'msg-own' }); + }); + }); + + describe('re-bootstrap on socket reconnect', () => { + it('given the socket reconnects after an initial connect, should re-run the DB bootstrap', async () => { + const { rerender } = renderHook(() => useChannelStreamSocket('page-a')); + await act(async () => { await Promise.resolve(); }); + + const bootstrapCallsAfterMount = mockFetchWithAuth.mock.calls.length; + + // First connect — already covered by the mount bootstrap, must NOT refetch. + mockConnectionStatus = 'connected'; + await act(async () => { rerender(); await Promise.resolve(); }); + expect(mockFetchWithAuth.mock.calls.length).toBe(bootstrapCallsAfterMount); + + // Drop, then reconnect. + mockConnectionStatus = 'disconnected'; + await act(async () => { rerender(); await Promise.resolve(); }); + mockConnectionStatus = 'connected'; + await act(async () => { rerender(); await Promise.resolve(); }); + + expect(mockFetchWithAuth.mock.calls.length).toBe(bootstrapCallsAfterMount + 1); + expect(mockFetchWithAuth).toHaveBeenLastCalledWith( + '/api/ai/chat/active-streams?channelId=page-a', + expect.anything(), + ); + }); + + it('given the very first connect, should NOT re-bootstrap (the mount already did)', async () => { + const { rerender } = renderHook(() => useChannelStreamSocket('page-a')); + await act(async () => { await Promise.resolve(); }); + + const callsAfterMount = mockFetchWithAuth.mock.calls.length; + + mockConnectionStatus = 'connecting'; + await act(async () => { rerender(); await Promise.resolve(); }); + mockConnectionStatus = 'connected'; + await act(async () => { rerender(); await Promise.resolve(); }); + + expect(mockFetchWithAuth.mock.calls.length).toBe(callsAfterMount); + }); + + it('given no channelId, should never bootstrap on reconnect', async () => { + const { rerender } = renderHook(() => useChannelStreamSocket(undefined)); + await act(async () => { await Promise.resolve(); }); + + mockConnectionStatus = 'connected'; + await act(async () => { rerender(); await Promise.resolve(); }); + mockConnectionStatus = 'disconnected'; + await act(async () => { rerender(); await Promise.resolve(); }); + mockConnectionStatus = 'connected'; + await act(async () => { rerender(); await Promise.resolve(); }); + + expect(mockFetchWithAuth).not.toHaveBeenCalled(); + }); + }); + describe('bootstrap unmount safety', () => { it('given fetch resolves after unmount, should not call addStream', async () => { let resolveFetch!: (value: unknown) => void; @@ -1233,7 +1526,13 @@ describe('useChannelStreamSocket', () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(onOwnStreamBootstrap).toHaveBeenCalledTimes(1); - expect(onOwnStreamBootstrap).toHaveBeenCalledWith({ messageId: 'msg-own-bootstrap' }); + // The STREAM's conversation, asserted exactly — not expect.any(String), which would have + // accepted the channelId ('page-a'), the messageId, or ''. This value decides which + // conversation's Stop button lights up; a wrong one lights the wrong surface. + expect(onOwnStreamBootstrap).toHaveBeenCalledWith({ + messageId: 'msg-own-bootstrap', + conversationId: 'conv-1', + }); }); it('given a bootstrapped stream from a remote tab, should not fire onOwnStreamBootstrap', async () => { diff --git a/apps/web/src/hooks/useAppStateRecovery.ts b/apps/web/src/hooks/useAppStateRecovery.ts index 6095ae4e89..2744b90806 100644 --- a/apps/web/src/hooks/useAppStateRecovery.ts +++ b/apps/web/src/hooks/useAppStateRecovery.ts @@ -11,10 +11,17 @@ export interface UseAppStateRecoveryOptions { onResume: () => Promise | void; /** - * Whether to enable recovery (e.g., disable during streaming) + * Whether to enable recovery. + * + * PREFER THE CALLBACK FORM. A boolean is captured at render, and iOS freezes JS + * the moment the app backgrounds — so the value that ends up gating the resume is + * whatever was true when the app went away, not what is true when it comes back. + * That is how the AI-page recovery path came to be dead in exactly the case it + * was written for. A callback is evaluated at fire time. + * * @default true */ - enabled?: boolean; + enabled?: boolean | (() => boolean); /** * Minimum time in background before triggering recovery (ms) @@ -49,7 +56,8 @@ export interface UseAppStateRecoveryOptions { * // Fetch latest messages from database * await mutate(); // SWR refresh * }, - * enabled: !isStreaming, // Don't interrupt active streams + * // Callback form — evaluated on resume, not captured at render. + * enabled: () => !useEditingStore.getState().isAnyEditing(), * }); * ``` */ @@ -62,6 +70,10 @@ export function useAppStateRecovery({ const backgroundStartRef = useRef(null); const onResumeRef = useRef(onResume); const pendingRefreshRef = useRef(false); + // Assigned during render so handleResume reads the latest gate without listing + // `enabled` as a dependency (which would re-register the listeners on every flip). + const enabledRef = useRef(enabled); + enabledRef.current = enabled; // Keep callback ref updated useEffect(() => { @@ -78,7 +90,10 @@ export function useAppStateRecovery({ ); const handleResume = useCallback(async () => { - if (!enabled) { + // Evaluated HERE, on resume — not captured at render. See `enabled` above. + const gate = enabledRef.current; + const isEnabled = typeof gate === 'function' ? gate() : gate; + if (!isEnabled) { log('Resume ignored - hook disabled'); return; } @@ -113,7 +128,7 @@ export function useAppStateRecovery({ pendingRefreshRef.current = false; backgroundStartRef.current = null; } - }, [enabled, minBackgroundTime, log]); + }, [minBackgroundTime, log]); // Capacitor app state listener useEffect(() => { diff --git a/apps/web/src/hooks/useChannelStreamSocket.ts b/apps/web/src/hooks/useChannelStreamSocket.ts index bafc2bf614..e6de811a1d 100644 --- a/apps/web/src/hooks/useChannelStreamSocket.ts +++ b/apps/web/src/hooks/useChannelStreamSocket.ts @@ -1,10 +1,18 @@ import { useCallback, useEffect, useRef } from 'react'; import { useSocket } from './useSocket'; +import { useAuth } from './useAuth'; +import { useSocketStore } from '@/stores/useSocketStore'; import { usePendingStreamsStore } from '@/stores/usePendingStreamsStore'; import { consumeStreamJoin } from '@/lib/ai/core/stream-join-client'; import { getBrowserSessionId } from '@/lib/ai/core/browser-session-id'; import { fetchWithAuth } from '@/lib/auth/auth-fetch'; import { isOwnStream } from '@/lib/ai/streams/isOwnStream'; +import { shouldAttachStream } from '@/lib/ai/streams/shouldAttachStream'; +import { isChannelConsuming } from '@/lib/ai/streams/consumingChannels'; +import { + shouldRefreshOnReconnect, + type ConnectionStatus, +} from '@/lib/ai/streams/shouldRefreshOnReconnect'; import { shouldSkipBootstrappedStream } from '@/lib/ai/streams/shouldSkipBootstrappedStream'; import { claimBootstrapConsumer, releaseBootstrapConsumer } from '@/lib/ai/streams/bootstrapConsumerGuard'; import { isValidPartFrame } from '@/lib/ai/streams/isValidPartFrame'; @@ -36,12 +44,42 @@ interface ActiveStreamRow { } export interface UseChannelStreamSocketOptions { - /** Fires once per messageId on clean finalize (SSE resolve or socket complete); NOT on SSE error. */ - onStreamComplete?: (messageId: string, conversationId?: string) => void; + /** + * Fires once per messageId on finalize (SSE resolve, or socket complete). + * + * `info.joinFailed` means the SSE join never succeeded — usually because the stream + * lives on another web instance, whose in-process multicast registry we cannot reach. + * The stream itself was fine; we just couldn't watch it. Whatever parts the store held + * are a stale snapshot at best, so the store entry has ALREADY been dropped and the + * consumer must reload the (durably persisted) message from the DB rather than + * synthesize a bubble from what it has. Consumers that key on "is there still a store + * entry?" would otherwise silently do nothing and lose the reply. + */ + onStreamComplete?: ( + messageId: string, + conversationId?: string, + info?: { joinFailed: boolean }, + ) => void; /** Fires once per messageId when DB bootstrap finds an in-flight stream from this browser session. */ - onOwnStreamBootstrap?: (event: { messageId: string }) => void; + onOwnStreamBootstrap?: (event: { messageId: string; conversationId: string }) => void; /** Fires once per own-bootstrapped messageId on any finalize path (resolve, complete, or error). */ onOwnStreamFinalize?: (event: { messageId: string }) => void; + /** + * Fires after every bootstrap with the messageIds the server says are STILL live on this + * channel. The authoritative answer to "is the stream I am holding state for still + * running?". + * + * Consumers hold ownership state (a claimed Stop slot, a streaming flag) that is only + * ever released by `onOwnStreamFinalize` — and there are paths where that event can + * never fire. The socket effect tears down and rebuilds on a socket-instance swap (an + * `auth:refreshed` reconnect builds a brand-new `io()`), and on teardown it deliberately + * does NOT finalize; if the stream ended during that gap, nothing is left to announce it + * and the consumer's flag strands `true` forever — a Stop button over a dead stream and, + * on the surfaces that OR it into `useStreamingRegistration`, permanent SWR suppression. + * + * Bootstrap already knows the truth, so it tells you: reconcile your claim against this. + */ + onActiveStreamsSnapshot?: (liveMessageIds: ReadonlySet) => void; /** * Fires when a remote user submits a message in this channel. * @@ -112,6 +150,12 @@ export function useChannelStreamSocket( options?: UseChannelStreamSocketOptions, ): { rejoinActiveStreams: () => void } { const socket = useSocket(); + // The signed-in user's id, so handleStreamStart can tell "my stream in another tab" + // from "another member's private conversation". Kept in a ref so it never re-registers + // the socket handlers. + const { user } = useAuth(); + const localUserIdRef = useRef(user?.id ?? null); + localUserIdRef.current = user?.id ?? null; // Holds the latest runBootstrap closure so rejoinActiveStreams can call it without // needing to be in the effect's dep array (the effect re-creates this on every run). const bootstrapRef = useRef<(() => void) | null>(null); @@ -119,6 +163,7 @@ export function useChannelStreamSocket( const onStreamCompleteRef = useRef(options?.onStreamComplete); const onOwnStreamBootstrapRef = useRef(options?.onOwnStreamBootstrap); const onOwnStreamFinalizeRef = useRef(options?.onOwnStreamFinalize); + const onActiveStreamsSnapshotRef = useRef(options?.onActiveStreamsSnapshot); const onUserMessageRef = useRef(options?.onUserMessage); const onMessageEditedRef = useRef(options?.onMessageEdited); const onMessageDeletedRef = useRef(options?.onMessageDeleted); @@ -130,6 +175,7 @@ export function useChannelStreamSocket( onStreamCompleteRef.current = options?.onStreamComplete; onOwnStreamBootstrapRef.current = options?.onOwnStreamBootstrap; onOwnStreamFinalizeRef.current = options?.onOwnStreamFinalize; + onActiveStreamsSnapshotRef.current = options?.onActiveStreamsSnapshot; onUserMessageRef.current = options?.onUserMessage; onMessageEditedRef.current = options?.onMessageEdited; onMessageDeletedRef.current = options?.onMessageDeleted; @@ -152,14 +198,25 @@ export function useChannelStreamSocket( // Bootstrap-discovered own-stream messageIds. Acts as both an "is-own" // lookup and a one-shot guard for onOwnStreamFinalize. const ownStreamIds = new Set(); + // Streams whose SSE join failed. The common cause is benign but important: + // the multicast registry is per-process, so a stream owned by another web + // instance 404s here. Such a stream is still very much alive server-side — + // we just can't watch its tokens. What we CAN do is wait for its + // chat:stream_complete and reload the (durably persisted) message from the + // DB, which is what this set enables in handleStreamComplete below. + const joinFailed = new Set(); const { addStream, appendPart, removeStream, clearPageStreams } = usePendingStreamsStore.getState(); - const fireComplete = (messageId: string, conversationId?: string) => { + const fireComplete = ( + messageId: string, + conversationId?: string, + info?: { joinFailed: boolean }, + ) => { if (processed.has(messageId)) return; processed.add(messageId); - onStreamCompleteRef.current?.(messageId, conversationId); + onStreamCompleteRef.current?.(messageId, conversationId, info); }; const fireOwnFinalize = (messageId: string) => { @@ -194,7 +251,7 @@ export function useChannelStreamSocket( controllers.delete(messageId); releaseBootstrapConsumer(messageId); try { - fireComplete(messageId, conversationId); + fireComplete(messageId, conversationId, { joinFailed: false }); } finally { removeStream(messageId); fireOwnFinalize(messageId); @@ -204,10 +261,23 @@ export function useChannelStreamSocket( if (cancelled) return; controllers.delete(messageId); releaseBootstrapConsumer(messageId); - // Mark as processed so a subsequent chat:stream_complete event for - // the same messageId is a no-op for onStreamComplete: the catch - // path already finalized this stream locally. - processed.add(messageId); + // Deliberately NOT processed.add(messageId) here. A failed join does not + // mean the stream failed — the usual cause is that it lives on another web + // instance, where the per-process multicast registry can't see it. It is + // still generating, and it will still broadcast chat:stream_complete when + // it finishes. Suppressing that event (which is what processed.add did) + // left the finished assistant message unloaded: the ghost vanished and + // nothing replaced it. Marking the join as failed instead lets + // handleStreamComplete reload the persisted message from the DB. + // + // Unless completion already happened: a join commonly 404s *because* the + // stream just ended and the registry entry was deleted, in which case + // chat:stream_complete can land before this rejection settles. It has + // already finalized the stream, so there is nothing left to mark — and + // adding the id here would leave an entry no later event ever clears. + if (!processed.has(messageId)) { + joinFailed.add(messageId); + } // When the store was seeded from a persisted snapshot // (skipReplayCount > 0), a failed join usually means the // originator's process died — its in-memory registry is gone and @@ -229,7 +299,15 @@ export function useChannelStreamSocket( // (or a mobile resume) doesn't lose visibility on what's currently happening // in this channel. Re-running is idempotent: claimBootstrapConsumer + // shouldSkipBootstrappedStream skip streams already being consumed. + // runBootstrap can be in flight more than once (mount, the reconnect effect, and + // rejoinActiveStreams all trigger it independently) and responses are not ordered. A + // slow older response landing after a newer one would carry a snapshot that predates a + // claim made in between — and releasing on it would kill the Stop button for a live + // stream. Only the newest run's snapshot is authoritative. + let bootstrapGeneration = 0; + const runBootstrap = async () => { + const generation = (bootstrapGeneration += 1); try { const res = await fetchWithAuth( `/api/ai/chat/active-streams?channelId=${encodeURIComponent(channelId)}`, @@ -242,6 +320,11 @@ export function useChannelStreamSocket( for (const stream of data.streams ?? []) { if (shouldSkipBootstrappedStream(stream.messageId, processed, controllers)) continue; const isOwn = isOwnStream(stream.triggeredBy, localBrowserSessionId); + // Bootstrap also re-runs on socket reconnect, which can land while this + // tab's useChat is mid-POST on its own stream — attaching to the multicast + // as well would render every remaining token twice. `isOwn` alone can't + // tell us that (it survives a reload; consuming state doesn't). + if (!shouldAttachStream({ isOwn, isConsuming: isChannelConsuming(channelId) })) continue; // Seed the persisted snapshot as the stream's initial parts so the // restored mid-stream content renders immediately — without waiting // on (or depending on) the live multicast, which is unavailable if @@ -271,10 +354,22 @@ export function useChannelStreamSocket( }); if (isOwn) { ownStreamIds.add(stream.messageId); - onOwnStreamBootstrapRef.current?.({ messageId: stream.messageId }); + onOwnStreamBootstrapRef.current?.({ + messageId: stream.messageId, + conversationId: stream.conversationId, + }); } startConsume(stream.messageId, stream.conversationId, persistedParts.length); } + + // The server's word on what is still running. Consumers reconcile any ownership + // state they are holding against it — see onActiveStreamsSnapshot. Superseded runs + // stay silent: their answer is stale, and acting on it releases live claims. + if (generation === bootstrapGeneration) { + onActiveStreamsSnapshotRef.current?.( + new Set((data.streams ?? []).map((stream) => stream.messageId)), + ); + } } catch (err) { if (cancelled) return; console.warn('[useChannelStreamSocket] bootstrap failed', err); @@ -289,7 +384,38 @@ export function useChannelStreamSocket( const handleStreamStart = (payload: AiStreamStartPayload) => { if (payload.pageId !== channelId) return; - if (isOwnStream(payload.triggeredBy, localBrowserSessionId)) return; + // Not ours to watch. A page room holds every member of the page, but conversations + // are private by default (`listConversations` shows you only `userId = you OR + // isShared`), so most streams on this channel are somebody else's private chat. + // The server refuses those joins anyway — this just avoids making the request: + // otherwise every member's client fires a doomed /stream-join per assistant + // message, each one an authz denial in the audit log. + // + // Skip ONLY on certainty, in both directions: + // + // - `isShared` must be an explicit `false`. During a rolling deploy an + // originator on the previous build sends no such field, and dropping on + // `undefined` would black out multiplayer for shared conversations. + // - we must actually KNOW who we are. `useAuth()` returns `user: null` until + // auth resolves, and treating "unknown" as "not me" would drop the user's OWN + // private stream in that window — the precise failure this whole PR exists to + // fix. + // + // When in doubt, attach and let the server decide: an unauthorized join is a quiet + // 404 that the client already handles benignly. The server is the authority here; + // this check exists only to avoid firing a request that is certain to be refused. + const localUserId = localUserIdRef.current; + const startedBySomeoneElse = localUserId !== null && payload.triggeredBy.userId !== localUserId; + if (startedBySomeoneElse && payload.isShared === false) return; + + const isOwn = isOwnStream(payload.triggeredBy, localBrowserSessionId); + // The ONLY reason to decline a live stream: this browser context is already + // reading its tokens off the POST body, so joining the multicast too would + // double-render. This used to be a blanket `isOwn` skip, which meant a + // reloaded tab — still "own" via sessionStorage, but consuming nothing — + // dropped its own stream forever and showed an empty chat while the server + // kept generating. See consumingChannels.ts / shouldAttachStream.ts. + if (!shouldAttachStream({ isOwn, isConsuming: isChannelConsuming(channelId) })) return; if (controllers.has(payload.messageId)) return; addStream({ @@ -297,10 +423,18 @@ export function useChannelStreamSocket( pageId: payload.pageId, conversationId: payload.conversationId, triggeredBy: payload.triggeredBy, - isOwn: false, + isOwn, startedAt: payload.startedAt, }); + if (isOwn) { + ownStreamIds.add(payload.messageId); + onOwnStreamBootstrapRef.current?.({ + messageId: payload.messageId, + conversationId: payload.conversationId, + }); + } + startConsume(payload.messageId, payload.conversationId); }; @@ -311,8 +445,17 @@ export function useChannelStreamSocket( controller.abort(); controllers.delete(payload.messageId); } + // If the SSE join failed, whatever parts we hold are a stale snapshot at best + // — the authoritative message is in the DB. Dropping the store entry BEFORE + // firing makes consumers take their reload-from-DB branch + // (shouldReloadOnComountComplete) instead of synthesizing a truncated bubble. + const didJoinFail = joinFailed.has(payload.messageId); + if (didJoinFail) { + joinFailed.delete(payload.messageId); + removeStream(payload.messageId); + } try { - fireComplete(payload.messageId, payload.conversationId); + fireComplete(payload.messageId, payload.conversationId, { joinFailed: didJoinFail }); } finally { removeStream(payload.messageId); fireOwnFinalize(payload.messageId); @@ -380,6 +523,21 @@ export function useChannelStreamSocket( controller.abort(); releaseBootstrapConsumer(msgId); } + // Release every own-stream claim we handed out. This is the ONLY chance: `cancelled` + // is latched on this effect closure, so every future runBootstrap — including the + // reconnect re-bootstrap and rejoinActiveStreams, which both call through + // bootstrapRef into THIS closure — returns early. onActiveStreamsSnapshot can + // therefore never fire again on this channel, and a consumer holding a claim would + // strand it forever: a Stop button over a stream it can no longer reach, and (on the + // surfaces that OR the flag into useStreamingRegistration) permanent SWR suppression. + // The effect only re-arms on a [socket, channelId] change, which may never come — + // the global channel's id is fixed for the whole session. + // + // Deliberately fired AFTER `cancelled` (unlike the SSE path, which must NOT run its + // clean-finalize here): this is an explicit teardown of ownership, not a completion. + for (const msgId of [...ownStreamIds]) { + fireOwnFinalize(msgId); + } clearPageStreams(channelId); }; @@ -419,5 +577,26 @@ export function useChannelStreamSocket( // Stable callback; the ref always points at the latest runBootstrap closure. const rejoinActiveStreams = useCallback(() => { bootstrapRef.current?.(); }, []); + // Re-bootstrap on socket reconnect. The effect above keys on [socket, channelId], + // and socket.io reconnects the SAME instance in place — so without this, a + // connection drop silently costs us every chat:stream_start emitted while we were + // away, and the tab stays blind to a live stream until it is reloaded. Bootstrap + // is idempotent (claimBootstrapConsumer + shouldSkipBootstrappedStream), so + // re-running it is safe. Mirrors useAgentChannelMultiplayer's reconnect handler. + const socketConnectionStatus = useSocketStore((s) => s.connectionStatus); + const prevConnectionStatusRef = useRef(null); + const hasInitialConnectRef = useRef(false); + useEffect(() => { + if (!channelId) return; + const prev = prevConnectionStatusRef.current; + prevConnectionStatusRef.current = socketConnectionStatus; + if (shouldRefreshOnReconnect(prev, socketConnectionStatus, hasInitialConnectRef.current)) { + bootstrapRef.current?.(); + } + if (prev !== 'connected' && socketConnectionStatus === 'connected') { + hasInitialConnectRef.current = true; + } + }, [channelId, socketConnectionStatus]); + return { rejoinActiveStreams }; } diff --git a/apps/web/src/lib/ai/core/__tests__/abort-conversation-streams.test.ts b/apps/web/src/lib/ai/core/__tests__/abort-conversation-streams.test.ts new file mode 100644 index 0000000000..b070604836 --- /dev/null +++ b/apps/web/src/lib/ai/core/__tests__/abort-conversation-streams.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const { mockSelectWhere, mockAbortStreamByMessageId } = vi.hoisted(() => ({ + mockSelectWhere: vi.fn(), + mockAbortStreamByMessageId: vi.fn(), +})); + +vi.mock('@pagespace/db/db', () => ({ + db: { + select: vi.fn(() => ({ from: vi.fn(() => ({ where: mockSelectWhere })) })), + }, +})); + +vi.mock('@pagespace/db/operators', () => ({ + eq: vi.fn((field, value) => ({ kind: 'eq', field, value })), + and: vi.fn((...conds) => ({ kind: 'and', conds })), +})); + +vi.mock('@pagespace/db/schema/ai-streams', () => ({ + aiStreamSessions: { + messageId: 'ai_stream_sessions.message_id', + conversationId: 'ai_stream_sessions.conversation_id', + userId: 'ai_stream_sessions.user_id', + status: 'ai_stream_sessions.status', + }, +})); + +vi.mock('../stream-abort-registry', () => ({ + abortStreamByMessageId: mockAbortStreamByMessageId, +})); + +vi.mock('@pagespace/lib/logging/logger-config', () => ({ + loggers: { ai: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() } }, +})); + +import { abortConversationStreams } from '../abort-conversation-streams'; + +describe('abortConversationStreams', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSelectWhere.mockResolvedValue([]); + mockAbortStreamByMessageId.mockReturnValue({ aborted: true, reason: '' }); + }); + + // THE WINDOW THIS EXISTS FOR. streamId and messageId are both minted server-side and unknown to + // the client until the response headers land — but a real agent send spends 0.5-3s before that + // (auth, rate limit, DB reads, context assembly, provider connect). Stop pressed in that window + // had NOTHING to name: the abort was a guaranteed no-op, the fetch was cancelled, and the button + // flipped back to Send — while the server, which deliberately survives client disconnect, kept + // generating, kept running write tools, and kept billing. + it('aborts the in-flight stream named only by its conversation', async () => { + mockSelectWhere.mockResolvedValue([{ messageId: 'msg-1' }]); + + const result = await abortConversationStreams({ conversationId: 'conv-1', userId: 'user-1' }); + + expect(result.aborted).toEqual(['msg-1']); + expect(mockAbortStreamByMessageId).toHaveBeenCalledWith({ messageId: 'msg-1', userId: 'user-1' }); + }); + + it('aborts every in-flight stream on the conversation', async () => { + mockSelectWhere.mockResolvedValue([{ messageId: 'msg-1' }, { messageId: 'msg-2' }]); + + const result = await abortConversationStreams({ conversationId: 'conv-1', userId: 'user-1' }); + + expect(result.aborted).toEqual(['msg-1', 'msg-2']); + }); + + describe('authorization', () => { + // DELIBERATELY STRICTER THAN THE TAKEOVER. takeOverConversationStreams aborts as the STREAM's + // owner (row.userId), because a second send on a SHARED conversation must be able to take over + // a co-member's generation. This is an explicit user STOP, and it may only ever stop the + // caller's OWN streams — otherwise naming someone else's conversation would kill their agent + // mid-answer. + it("scopes the query to the CALLER's own streams — a user cannot stop someone else's by naming their conversation", async () => { + await abortConversationStreams({ conversationId: 'conv-1', userId: 'user-1' }); + + const predicate = mockSelectWhere.mock.calls[0][0] as { + conds: Array<{ field?: string; value?: unknown }>; + }; + const userCond = predicate.conds.find((c) => c.field === 'ai_stream_sessions.user_id'); + + expect(userCond).toBeDefined(); + expect(userCond?.value).toBe('user-1'); + }); + + it('passes the CALLER id to the registry, so its ownership re-check is meaningful', async () => { + mockSelectWhere.mockResolvedValue([{ messageId: 'msg-1' }]); + + await abortConversationStreams({ conversationId: 'conv-1', userId: 'user-1' }); + + // NOT the row's userId — that is the takeover's rule, and it would defeat the registry's guard. + expect(mockAbortStreamByMessageId).toHaveBeenCalledWith({ messageId: 'msg-1', userId: 'user-1' }); + }); + + it('only considers rows still streaming', async () => { + await abortConversationStreams({ conversationId: 'conv-1', userId: 'user-1' }); + + const predicate = mockSelectWhere.mock.calls[0][0] as { + conds: Array<{ field?: string; value?: unknown }>; + }; + const statusCond = predicate.conds.find((c) => c.field === 'ai_stream_sessions.status'); + expect(statusCond?.value).toBe('streaming'); + }); + }); + + describe('honest reporting', () => { + it('given no in-flight stream, says so rather than claiming success', async () => { + const result = await abortConversationStreams({ conversationId: 'conv-1', userId: 'user-1' }); + + expect(result.aborted).toEqual([]); + expect(result.reason).toBe('No in-flight stream on this conversation'); + }); + + // The abort registry is in-process. A stream owned by another web instance is real and running + // but cannot be stopped from here — reporting that as success would be a lie. + it('given a stream that exists but could not be aborted from this instance, says so', async () => { + mockSelectWhere.mockResolvedValue([{ messageId: 'msg-elsewhere' }]); + mockAbortStreamByMessageId.mockReturnValue({ aborted: false, reason: 'not found' }); + + const result = await abortConversationStreams({ conversationId: 'conv-1', userId: 'user-1' }); + + expect(result.aborted).toEqual([]); + expect(result.reason).toContain('none could be aborted from this instance'); + }); + + it('given the lookup throws, does not throw at the caller', async () => { + mockSelectWhere.mockRejectedValue(new Error('db down')); + + await expect( + abortConversationStreams({ conversationId: 'conv-1', userId: 'user-1' }), + ).resolves.toEqual({ aborted: [], reason: 'Lookup failed' }); + }); + }); +}); diff --git a/apps/web/src/lib/ai/core/__tests__/disconnect-immunity.test.ts b/apps/web/src/lib/ai/core/__tests__/disconnect-immunity.test.ts new file mode 100644 index 0000000000..92312ee081 --- /dev/null +++ b/apps/web/src/lib/ai/core/__tests__/disconnect-immunity.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * The load-bearing invariant of server-owned streams, and the only one with no natural place + * to assert itself. + * + * A stream is a SERVER-SIDE entity. The client is a subscriber. The whole architecture rests on + * one fact: the generation does not die when the client goes away. That is why `request.signal` + * is never wired into `streamText` — an aborted HTTP request means "this client stopped + * listening", NOT "the user wants the agent to stop". Only an explicit user stop aborts a + * stream, via the abort registry keyed by messageId. + * + * Wire `request.signal` in and everything silently reverts to client-owned streams: reloading + * the tab (which iOS does constantly — it jetsams the WKWebView content process and Capacitor + * calls webView.reload()) kills the generation mid-tool-call, mid-page-edit. Nothing fails + * loudly. No test breaks. The tab just quietly starts murdering its own agent again, and the + * bug this entire PR exists to fix comes back. + * + * It is a one-line change to break, it looks like an obvious cleanup ("we're leaking a + * generation when the client disconnects!"), and it is invisible in review unless you already + * know. So it gets a tripwire. + * + * This is deliberately a SOURCE-LEVEL assertion. A behavioural test would have to mock + * `streamText` and drive an aborted request through the whole route — heavy, and it would only + * pin the one path it exercised. What we actually want to forbid is the *presence* of the + * wiring, anywhere in the generation routes, forever. + * + * If this test fails, do not "fix" it by editing the allowlist. Read `stream-abort-registry.ts` + * and `route.ts`'s abort-controller comment first, then decide whether you really mean it. + */ + +const WEB_SRC = join(import.meta.dirname, '../../../..'); + +// The two routes that START a generation. These may never observe the request's abort signal. +const GENERATION_ROUTES = [ + 'app/api/ai/chat/route.ts', + 'app/api/ai/global/[id]/messages/route.ts', +]; + +// Every realistic way to get the request's abort signal. +// +// The first pattern alone was NOT enough, and I only found that by trying to defeat my own +// guard: `const { signal } = request;` — the most natural way a developer would actually write +// it — sailed straight past a member-access-only check. The test stayed green while +// disconnect-immunity was destroyed, which is precisely the false comfort this file exists to +// prevent. A tripwire that only catches the spelling you happened to think of is not a tripwire. +const REQUEST_SIGNAL_PATTERNS = [ + // request.signal / req.signal + /\b(request|req)\s*\.\s*signal\b/, + // const { signal } = request / const { signal: alias } = req + /\{[^}]*\bsignal\b[^}]*\}\s*=\s*(request|req)\b/, +]; + +// This is a heuristic, deliberately. It cannot catch every conceivable aliasing +// (`const r = request; r.signal`), and it is not trying to be a type system. It catches the ways +// this mistake actually gets made — a direct read and a destructure — and it fails loudly with an +// explanation, which is worth far more than a proof nobody writes. +const readsRequestSignal = (line: string): boolean => + REQUEST_SIGNAL_PATTERNS.some((re) => re.test(line)); + +describe('disconnect-immunity (AC7)', () => { + describe.each(GENERATION_ROUTES)('%s', (relPath) => { + it('never reads the incoming request\'s abort signal — a client hanging up must not kill the generation', () => { + const source = readFileSync(join(WEB_SRC, relPath), 'utf8'); + + const offendingLines = source + .split('\n') + .map((line, i) => ({ line: line.trim(), lineNo: i + 1 })) + .filter(({ line }) => readsRequestSignal(line)) + // A comment explaining WHY we don't do this is exactly what we want to keep. + .filter(({ line }) => !line.startsWith('//') && !line.startsWith('*') && !line.startsWith('/*')); + + expect( + offendingLines, + `${relPath} reads the request's abort signal. A stream is a server-owned entity: the ` + + `client disconnecting means it stopped LISTENING, not that the user pressed Stop. ` + + `Wiring this into streamText (or into AbortSignal.any alongside the stream's own ` + + `controller) makes every tab reload kill its own in-flight generation mid-tool-call — ` + + `the exact bug server-owned streams exist to fix, and it fails silently.\n` + + `Only an explicit user stop may abort: see abortStreamByMessageId in ` + + `stream-abort-registry.ts.`, + ).toEqual([]); + }); + }); + + // The subscriber side is the mirror image, and MUST use request.signal: an SSE reader going + // away has to detach from the multicast, or the registry leaks a subscriber per dead tab. + // Asserted so nobody "helpfully" applies the rule above to the wrong route. + it('stream-join DOES observe the request signal — a departing subscriber must detach', () => { + const source = readFileSync( + join(WEB_SRC, 'app/api/ai/chat/stream-join/[messageId]/route.ts'), + 'utf8', + ); + expect(readsRequestSignal(source)).toBe(true); + }); +}); diff --git a/apps/web/src/lib/ai/core/__tests__/stream-abort-client.test.ts b/apps/web/src/lib/ai/core/__tests__/stream-abort-client.test.ts index 4b700a88dd..9e8b45f265 100644 --- a/apps/web/src/lib/ai/core/__tests__/stream-abort-client.test.ts +++ b/apps/web/src/lib/ai/core/__tests__/stream-abort-client.test.ts @@ -148,6 +148,169 @@ describe('stream-abort-client', () => { }); }); + // AC1: the transport is the choke point where a client declares "I am consuming this + // stream's body". It is the ONLY reason the client's own chat:stream_start is + // uninteresting to it — and the reason a RELOADED tab (fresh module state, empty set) + // re-attaches to its own stream instead of dropping it forever. + describe('createStreamTrackingFetch — consuming-channel marking', () => { + const streamingResponse = (chunks: string[] = ['data']) => { + const encoder = new TextEncoder(); + let i = 0; + const body = new ReadableStream({ + pull(controller) { + if (i >= chunks.length) { + controller.close(); + return; + } + controller.enqueue(encoder.encode(chunks[i])); + i += 1; + }, + }); + return new Response(body, { status: 200 }); + }; + + const drain = async (response: Response) => { + const reader = response.body!.getReader(); + // eslint-disable-next-line no-constant-condition + while (true) { + const { done } = await reader.read(); + if (done) break; + } + }; + + it('given a POST in flight, should mark the channel as consuming BEFORE the request leaves (it must not lose the race against broadcastAiStreamStart)', async () => { + const client = await import('../stream-abort-client'); + const consuming = await import('@/lib/ai/streams/consumingChannels'); + + let markedAtRequestTime = false; + vi.mocked(fetchWithAuth).mockImplementationOnce(async () => { + markedAtRequestTime = consuming.isChannelConsuming('page-1'); + return streamingResponse(); + }); + + const trackingFetch = client.createStreamTrackingFetch({ getChatId: () => 'chat-1', getChannelId: () => 'page-1' }); + const response = await trackingFetch('/api/ai/chat', { method: 'POST' }); + + expect(markedAtRequestTime).toBe(true); + await drain(response); + }); + + // THE FREEZE. useChat only rebuilds its `Chat` when its `id` changes, every surface passes a + // CONSTANT id, and `Chat` binds its transport once in the constructor — so the FIRST transport + // a surface builds serves every POST for the life of that surface. Baking the keys into this + // closure froze them at whatever conversation/agent the surface happened to start with: after + // one switch the streamId was WRITTEN under the old chatId and READ under the new one (a + // guaranteed abort miss — the local fetch stops, the server keeps generating and billing), and + // the wrong channel was marked consuming (so the tab failed to recognise its OWN stream on the + // socket, joined its own multicast, and rendered the reply twice). + // + // The keys must be resolved at CALL time. One fetch, one switch, current keys. + it('given the surface switched conversation, should use the CURRENT keys — not the ones it was built with', async () => { + const client = await import('../stream-abort-client'); + const consuming = await import('@/lib/ai/streams/consumingChannels'); + + let chatId = 'conv-1'; + let channelId: string | undefined = 'agent-1'; + const trackingFetch = client.createStreamTrackingFetch({ + getChatId: () => chatId, + getChannelId: () => channelId, + }); + + // The surface moves on — exactly the switch the frozen closure could not see. + chatId = 'conv-2'; + channelId = 'agent-2'; + + let markedChannel: string | null = null; + vi.mocked(fetchWithAuth).mockImplementationOnce(async () => { + markedChannel = consuming.isChannelConsuming('agent-2') ? 'agent-2' + : consuming.isChannelConsuming('agent-1') ? 'agent-1' : null; + const base = streamingResponse(); + return new Response(base.body, { status: 200, headers: { 'X-Stream-Id': 'stream-xyz' } }); + }); + + const response = await trackingFetch('/api/ai/chat', { method: 'POST' }); + + // The CURRENT channel is marked, not the one the transport was born with. + expect(markedChannel).toBe('agent-2'); + // ...and the streamId is filed under the CURRENT conversation, so Stop can find it. + expect(client.getActiveStreamId({ chatId: 'conv-2' })).toBe('stream-xyz'); + expect(client.getActiveStreamId({ chatId: 'conv-1' })).toBeUndefined(); + + await drain(response); + }); + + it('given the response body finishes, should unmark the channel', async () => { + const client = await import('../stream-abort-client'); + const consuming = await import('@/lib/ai/streams/consumingChannels'); + + vi.mocked(fetchWithAuth).mockResolvedValueOnce(streamingResponse(['a', 'b'])); + + const trackingFetch = client.createStreamTrackingFetch({ getChatId: () => 'chat-1', getChannelId: () => 'page-1' }); + const response = await trackingFetch('/api/ai/chat', { method: 'POST' }); + + // Still consuming while tokens are arriving — the headers landing is NOT the end. + expect(consuming.isChannelConsuming('page-1')).toBe(true); + + await drain(response); + + expect(consuming.isChannelConsuming('page-1')).toBe(false); + }); + + it('given the body is cancelled (user hits Stop), should unmark the channel', async () => { + const client = await import('../stream-abort-client'); + const consuming = await import('@/lib/ai/streams/consumingChannels'); + + vi.mocked(fetchWithAuth).mockResolvedValueOnce(streamingResponse(['a', 'b', 'c'])); + + const trackingFetch = client.createStreamTrackingFetch({ getChatId: () => 'chat-1', getChannelId: () => 'page-1' }); + const response = await trackingFetch('/api/ai/chat', { method: 'POST' }); + + await response.body!.cancel('user stopped'); + + expect(consuming.isChannelConsuming('page-1')).toBe(false); + }); + + it('given the fetch rejects, should unmark the channel (a stale mark would make this tab ignore its own stream forever)', async () => { + const client = await import('../stream-abort-client'); + const consuming = await import('@/lib/ai/streams/consumingChannels'); + + vi.mocked(fetchWithAuth).mockRejectedValueOnce(new Error('network down')); + + const trackingFetch = client.createStreamTrackingFetch({ getChatId: () => 'chat-1', getChannelId: () => 'page-1' }); + + await expect(trackingFetch('/api/ai/chat', { method: 'POST' })).rejects.toThrow('network down'); + expect(consuming.isChannelConsuming('page-1')).toBe(false); + }); + + it('given a non-ok response (e.g. 402 out of credits), should unmark the channel', async () => { + const client = await import('../stream-abort-client'); + const consuming = await import('@/lib/ai/streams/consumingChannels'); + + vi.mocked(fetchWithAuth).mockResolvedValueOnce(new Response('{}', { status: 402 })); + + const trackingFetch = client.createStreamTrackingFetch({ getChatId: () => 'chat-1', getChannelId: () => 'page-1' }); + await trackingFetch('/api/ai/chat', { method: 'POST' }); + + expect(consuming.isChannelConsuming('page-1')).toBe(false); + }); + + it('given the tracked response, should preserve status and headers (the X-Stream-Id contract must survive the body re-wrap)', async () => { + const client = await import('../stream-abort-client'); + + const body = new ReadableStream({ start(c) { c.close(); } }); + vi.mocked(fetchWithAuth).mockResolvedValueOnce( + new Response(body, { status: 200, headers: { 'X-Stream-Id': 'stream-9' } }), + ); + + const trackingFetch = client.createStreamTrackingFetch({ getChatId: () => 'chat-1', getChannelId: () => 'page-1' }); + const response = await trackingFetch('/api/ai/chat', { method: 'POST' }); + + expect(response.status).toBe(200); + expect(response.headers.get('X-Stream-Id')).toBe('stream-9'); + expect(client.getActiveStreamId({ chatId: 'chat-1' })).toBe('stream-9'); + }); + }); + describe('createStreamTrackingFetch', () => { it('extracts X-Stream-Id header and stores it', async () => { const client = await import('../stream-abort-client'); @@ -160,7 +323,7 @@ describe('stream-abort-client', () => { vi.mocked(fetchWithAuth).mockResolvedValueOnce(mockResponse); - const trackingFetch = client.createStreamTrackingFetch({ chatId: 'chat-123' }); + const trackingFetch = client.createStreamTrackingFetch({ getChatId: () => 'chat-123', getChannelId: () => undefined }); await trackingFetch('/api/ai/chat', { method: 'POST' }); expect(mockResponse.headers.get).toHaveBeenCalledWith('X-Stream-Id'); @@ -178,7 +341,7 @@ describe('stream-abort-client', () => { vi.mocked(fetchWithAuth).mockResolvedValueOnce(mockResponse); - const trackingFetch = client.createStreamTrackingFetch({ chatId: 'chat-123' }); + const trackingFetch = client.createStreamTrackingFetch({ getChatId: () => 'chat-123', getChannelId: () => undefined }); await trackingFetch('/api/ai/chat', { method: 'POST' }); expect(client.getActiveStreamId({ chatId: 'chat-123' })).toBeUndefined(); @@ -195,7 +358,7 @@ describe('stream-abort-client', () => { vi.mocked(fetchWithAuth).mockResolvedValueOnce(mockResponse); - const trackingFetch = client.createStreamTrackingFetch({ chatId: 'chat-123' }); + const trackingFetch = client.createStreamTrackingFetch({ getChatId: () => 'chat-123', getChannelId: () => undefined }); const request = new Request('https://example.com/api/ai/chat'); await trackingFetch(request, {}); @@ -214,7 +377,7 @@ describe('stream-abort-client', () => { vi.mocked(fetchWithAuth).mockResolvedValueOnce(mockResponse); - const trackingFetch = client.createStreamTrackingFetch({ chatId: 'chat-123' }); + const trackingFetch = client.createStreamTrackingFetch({ getChatId: () => 'chat-123', getChannelId: () => undefined }); await trackingFetch('/api/ai/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/apps/web/src/lib/ai/core/__tests__/stream-lifecycle.test.ts b/apps/web/src/lib/ai/core/__tests__/stream-lifecycle.test.ts index b12f09b7e5..f2a1acddc7 100644 --- a/apps/web/src/lib/ai/core/__tests__/stream-lifecycle.test.ts +++ b/apps/web/src/lib/ai/core/__tests__/stream-lifecycle.test.ts @@ -1,10 +1,11 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; const { mockRegistryRegister, mockRegistryPush, mockRegistryFinish, mockRegistryGetBufferedParts, + mockRegistryGetMeta, mockBroadcastStart, mockBroadcastComplete, mockInsertValues, @@ -18,6 +19,7 @@ const { mockRegistryPush: vi.fn(), mockRegistryFinish: vi.fn(), mockRegistryGetBufferedParts: vi.fn().mockReturnValue([]), + mockRegistryGetMeta: vi.fn().mockReturnValue({ pageId: 'page-1', userId: 'u1', displayName: 'U', conversationId: 'conv-1', browserSessionId: 's1' }), mockBroadcastStart: vi.fn().mockResolvedValue(undefined), mockBroadcastComplete: vi.fn().mockResolvedValue(undefined), mockInsertValues: vi.fn(), @@ -34,7 +36,12 @@ vi.mock('@/lib/ai/core/stream-multicast-registry', () => ({ push: mockRegistryPush, finish: mockRegistryFinish, getBufferedParts: mockRegistryGetBufferedParts, - getMeta: vi.fn(), + // A REGISTERED stream has meta. The old fixture returned undefined — i.e. it modelled a + // stream whose registry entry was already evicted — which was harmless only because + // production never asked. It asks now: the parts checkpoint skips when the entry is gone, + // because `getBufferedParts()` then returns `[]` meaning "no entry", and persisting that + // would wipe the crash-recovery snapshot. + getMeta: mockRegistryGetMeta, subscribe: vi.fn(), }, })); @@ -101,6 +108,11 @@ const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolv describe('createStreamLifecycle', () => { beforeEach(() => { vi.clearAllMocks(); + // clearAllMocks() clears calls, NOT implementations — a mockReturnValue set inside one test + // otherwise leaks into every test after it. These two drive the parts-checkpoint guards, so + // a leak here silently changes what later tests are actually exercising. + mockRegistryGetBufferedParts.mockReturnValue([]); + mockRegistryGetMeta.mockReturnValue({ pageId: 'page-1', userId: 'u1', displayName: 'U', conversationId: 'conv-1', browserSessionId: 's1' }); mockInsertOnConflict.mockResolvedValue(undefined); mockUpdateWhere.mockResolvedValue(undefined); mockBroadcastStart.mockResolvedValue(undefined); @@ -132,6 +144,7 @@ describe('createStreamLifecycle', () => { browserSessionId: 'session-1', status: 'streaming', startedAt: expect.any(Date), + lastHeartbeatAt: expect.any(Date), }); }); @@ -183,6 +196,9 @@ describe('createStreamLifecycle', () => { pageId: 'page-1', conversationId: 'conv-1', startedAt: expect.any(String), + // Rides the broadcast so page members can tell a stream they may watch from a + // co-member's PRIVATE conversation, without firing a doomed join at the server. + isShared: false, triggeredBy: { userId: 'user-1', displayName: 'Alice', browserSessionId: 'session-1' }, }); }); @@ -277,7 +293,14 @@ describe('createStreamLifecycle', () => { await flushMicrotasks(); expect(mockUpdateSet).toHaveBeenCalledTimes(1); - expect(mockUpdateSet).toHaveBeenCalledWith({ parts: fakeParts }); + // The parts checkpoint ALSO refreshes lastHeartbeatAt, on top of the independent + // heartbeat timer (see the 'heartbeat' describe below) — so a busy stream's liveness + // is never staler than its most recent checkpoint. The timer is what covers a stream + // that pushes no parts at all, which no checkpoint-driven heartbeat could. + expect(mockUpdateSet).toHaveBeenCalledWith({ + parts: fakeParts, + lastHeartbeatAt: expect.any(Date), + }); }); it('given 40 pushes across two batches, should persist once per batch', async () => { @@ -327,6 +350,120 @@ describe('createStreamLifecycle', () => { }); }); + // Liveness must NOT ride the parts checkpoint. A stream sitting in a long tool call + // (sandbox exec, deep research, a slow MCP tool) pushes no parts for minutes — a + // checkpoint-driven heartbeat would declare a perfectly healthy stream dead: it would + // vanish from /active-streams so no client could attach, and the next send would fail + // to abort it and would generate alongside it. + describe('heartbeat — an independent timer, not the parts checkpoint', () => { + const textPart = { type: 'text' as const, text: 'hello' }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('given a stream that pushes NO parts at all (a long tool call), should still beat', async () => { + const lifecycle = await createStreamLifecycle(params()); + mockUpdateSet.mockClear(); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(mockUpdateSet).toHaveBeenCalled(); + // Heartbeat-only: it must never touch `parts`, or it could race the checkpoint writes. + for (const call of mockUpdateSet.mock.calls) { + expect(call[0]).toEqual({ lastHeartbeatAt: expect.any(Date) }); + } + + lifecycle.finish(false); + }); + + // Backstop. finish() clears the interval and every generation path reaches it — but + // an UNBOUNDED heartbeat on a lifecycle that somehow never finished would be strictly + // worse than no heartbeat: the row would look live forever, so it could never be + // reconciled and never be taken over (the abort registry drops its entry after 10 + // min), and would be served to clients as an unjoinable phantom stream for the life + // of the process. Capping the beat turns that immortal ghost back into an ordinary + // stale row. + it('given a lifecycle that never finishes, should stop beating after the cap rather than looking live forever', async () => { + await createStreamLifecycle(params()); + + // Still beating well inside the cap — a long-but-plausible generation must never be + // cut off, or the next send would drive its LIVE row terminal. + await vi.advanceTimersByTimeAsync(45 * 60 * 1000); + expect(mockUpdateSet).toHaveBeenCalled(); + + // Past the cap: the interval must have cancelled itself. + await vi.advanceTimersByTimeAsync(20 * 60 * 1000); + mockUpdateSet.mockClear(); + + await vi.advanceTimersByTimeAsync(60 * 60 * 1000); + + expect(mockUpdateSet).not.toHaveBeenCalled(); + }); + + // THE HOLE THE CAP DID NOT ACTUALLY CLOSE. + // + // Capping the interval beat is useless on its own, because `persistBufferedParts` ALSO + // writes lastHeartbeatAt — and the parts checkpoint used to run with no deadline at all. + // So the one generation most likely to outlive the cap (a long one, still chattering) kept + // refreshing its own liveness FOREVER, which is precisely the immortal ghost the cap exists + // to kill. And it is the worst possible ghost: by then BOTH registries have evicted it, so + // /active-streams advertises a live, joinable stream that no client can join and whose Stop + // button is a silent no-op, while the generation keeps running its tools and keeps billing. + it('given a stream still pushing parts past the horizon, should stop refreshing its heartbeat rather than look live forever', async () => { + mockRegistryGetBufferedParts.mockReturnValue([textPart, textPart]); + const lifecycle = await createStreamLifecycle(params()); + + // Past the horizon, but still generating hard. + await vi.advanceTimersByTimeAsync(61 * 60 * 1000); + mockUpdateSet.mockClear(); + + for (let i = 0; i < 60; i++) lifecycle.pushPart(textPart); + await vi.advanceTimersByTimeAsync(0); + + // Not one write. The row is allowed to go stale, so the next takeover can reconcile it. + expect(mockUpdateSet).not.toHaveBeenCalled(); + }); + + // The second half of the same bug: past the horizon the multicast registry has EVICTED the + // entry, so getBufferedParts() returns [] meaning "no entry" — not "no content". The old + // checkpoint serialized that [] straight over the parts column, erasing the crash-recovery + // snapshot a client needs to restore mid-stream content after the originator's process dies. + it('given the registry entry is gone, should not overwrite the parts snapshot with an empty array', async () => { + const lifecycle = await createStreamLifecycle(params()); + mockUpdateSet.mockClear(); + + // The registry evicted it (horizon), so it reports no entry and an empty buffer. + mockRegistryGetMeta.mockReturnValue(undefined); + mockRegistryGetBufferedParts.mockReturnValue([]); + + for (let i = 0; i < 40; i++) lifecycle.pushPart(textPart); + await vi.advanceTimersByTimeAsync(0); + + const wroteEmptyParts = mockUpdateSet.mock.calls.some( + (c) => Array.isArray((c[0] as { parts?: unknown }).parts) + && ((c[0] as { parts: unknown[] }).parts).length === 0, + ); + expect(wroteEmptyParts).toBe(false); + }); + + it('given the stream finishes, should stop beating', async () => { + const lifecycle = await createStreamLifecycle(params()); + + lifecycle.finish(false); + await vi.advanceTimersByTimeAsync(0); + mockUpdateSet.mockClear(); + + await vi.advanceTimersByTimeAsync(120_000); + + expect(mockUpdateSet).not.toHaveBeenCalled(); + }); + }); + describe('finish — completion path', () => { it('given finish(false), should call registry.finish, UPDATE the row to status=complete, and broadcast complete', async () => { const lifecycle = await createStreamLifecycle(params()); diff --git a/apps/web/src/lib/ai/core/__tests__/stream-liveness.test.ts b/apps/web/src/lib/ai/core/__tests__/stream-liveness.test.ts new file mode 100644 index 0000000000..e1838c0c71 --- /dev/null +++ b/apps/web/src/lib/ai/core/__tests__/stream-liveness.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'vitest'; +import { + isStreamRowLive, + decideStreamTakeover, + STREAM_HEARTBEAT_STALE_MS, +} from '../stream-liveness'; + +const NOW = new Date('2026-07-11T12:00:00.000Z').getTime(); +const ago = (ms: number) => new Date(NOW - ms); + +const row = (messageId: string, heartbeatAgoMs: number | null, startedAgoMs = heartbeatAgoMs ?? 0) => ({ + messageId, + lastHeartbeatAt: heartbeatAgoMs === null ? null : ago(heartbeatAgoMs), + startedAt: ago(startedAgoMs), +}); + +describe('isStreamRowLive', () => { + it('given a row that checkpointed seconds ago, should be live', () => { + expect(isStreamRowLive(row('m1', 5_000), NOW)).toBe(true); + }); + + it('given a row whose last checkpoint is older than the stale window, should be dead', () => { + expect(isStreamRowLive(row('m1', STREAM_HEARTBEAT_STALE_MS + 1), NOW)).toBe(false); + }); + + it('given a row exactly at the stale boundary, should be dead (the window is exclusive)', () => { + expect(isStreamRowLive(row('m1', STREAM_HEARTBEAT_STALE_MS), NOW)).toBe(false); + }); + + // Rows written before the heartbeat column existed carry null. Declaring an + // actually-in-flight stream dead under its own feet mid-deploy would be worse than + // being briefly generous, so fall back to startedAt. + it('given a pre-heartbeat row (null lastHeartbeatAt) that started recently, should fall back to startedAt and be live', () => { + expect(isStreamRowLive(row('m1', null, 10_000), NOW)).toBe(true); + }); + + it('given a pre-heartbeat row that started long ago, should be dead', () => { + expect(isStreamRowLive(row('m1', null, STREAM_HEARTBEAT_STALE_MS * 2), NOW)).toBe(false); + }); + + it('given an explicit staleAfterMs, should use it instead of the default', () => { + expect(isStreamRowLive(row('m1', 5_000), NOW, 1_000)).toBe(false); + expect(isStreamRowLive(row('m1', 5_000), NOW, 60_000)).toBe(true); + }); +}); + +describe('decideStreamTakeover', () => { + it('given no in-flight rows, should decide nothing', () => { + expect(decideStreamTakeover({ rows: [], now: NOW })).toEqual({ abort: [], reconcile: [] }); + }); + + // THE invariant. Aborting is free for a messageId the in-process registry doesn't + // know (abortStreamByMessageId returns {aborted:false}; it does not throw), whereas + // SKIPPING an abort for a row we misjudged as dead leaves a real generation running + // and starts a second one alongside it — two agents editing the same pages, two + // bills. The asymmetry is total, so liveness must never gate the abort. + it('given a row that LOOKS stale, should still attempt to abort it (a liveness guess must never gate the abort)', () => { + const decision = decideStreamTakeover({ + rows: [row('looks-dead', STREAM_HEARTBEAT_STALE_MS * 3)], + now: NOW, + }); + + expect(decision.abort).toEqual(['looks-dead']); + }); + + it('given live and stale rows, should attempt to abort EVERY one of them', () => { + const decision = decideStreamTakeover({ + rows: [row('live', 1_000), row('dead', STREAM_HEARTBEAT_STALE_MS * 2)], + now: NOW, + }); + + expect(decision.abort).toEqual(['live', 'dead']); + }); + + describe('reconcile — only what we can prove is finished', () => { + it('given a row the registry actually aborted, should reconcile it', () => { + const decision = decideStreamTakeover({ + rows: [row('live', 1_000)], + abortedMessageIds: ['live'], + now: NOW, + }); + + expect(decision.reconcile).toEqual(['live']); + }); + + // A crashed generation leaves status='streaming' forever — the terminal write is + // fire-and-forget and dies with the process. Nothing to abort, but it must be + // driven terminal so it stops blocking and stops ghosting. + it('given a provably dead row (stale heartbeat) that no registry aborted, should still reconcile it', () => { + const decision = decideStreamTakeover({ + rows: [row('dead', STREAM_HEARTBEAT_STALE_MS * 3)], + abortedMessageIds: [], + now: NOW, + }); + + expect(decision.reconcile).toEqual(['dead']); + }); + + // The dangerous case. The abort was refused (cross-user IDOR guard on a shared + // conversation) or not-found (the stream lives on another web instance) — but the + // row is beating, so the generation is very much alive. Writing + // status='aborted', parts=[] over it would hide a live stream from every + // subscriber and destroy its only crash-recovery snapshot, while it keeps running, + // keeps calling tools, and keeps billing. + it('given a LIVE row we could not abort, should NOT reconcile it (never mark a running stream aborted)', () => { + const decision = decideStreamTakeover({ + rows: [row('live-elsewhere', 1_000)], + abortedMessageIds: [], + now: NOW, + }); + + expect(decision.abort).toEqual(['live-elsewhere']); + expect(decision.reconcile).toEqual([]); + }); + + it('given a mix, should reconcile the aborted and the dead but leave the un-abortable live row alone', () => { + const decision = decideStreamTakeover({ + rows: [ + row('stopped', 1_000), + row('dead', STREAM_HEARTBEAT_STALE_MS * 2), + row('live-elsewhere', 2_000), + ], + abortedMessageIds: ['stopped'], + now: NOW, + }); + + expect(decision.abort).toEqual(['stopped', 'dead', 'live-elsewhere']); + expect(decision.reconcile).toEqual(['stopped', 'dead']); + }); + }); +}); diff --git a/apps/web/src/lib/ai/core/__tests__/stream-multicast-registry.test.ts b/apps/web/src/lib/ai/core/__tests__/stream-multicast-registry.test.ts index b3e7063bff..00e67524ac 100644 --- a/apps/web/src/lib/ai/core/__tests__/stream-multicast-registry.test.ts +++ b/apps/web/src/lib/ai/core/__tests__/stream-multicast-registry.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; +import { STREAM_MAX_LIFETIME_MS } from '../stream-horizons'; import { StreamMulticastRegistry, type StreamMeta, @@ -224,7 +225,11 @@ describe('StreamMulticastRegistry', () => { }); describe('auto-cleanup', () => { - it('given a stream still open after 10 minutes, should auto-cleanup to prevent memory leaks', () => { + // The eviction horizon is shared with the abort registry and the heartbeat cap + // (STREAM_MAX_LIFETIME_MS). It must NOT be shorter than the heartbeat: a still-running + // long generation would then be reported live by /active-streams while no client could + // join it and its Stop button had already become a no-op. + it('given a stream still open past the shared lifetime horizon, should auto-cleanup to prevent memory leaks', () => { vi.useFakeTimers(); const registry = new StreamMulticastRegistry(); registry.register('msg-1', meta()); @@ -234,13 +239,13 @@ describe('StreamMulticastRegistry', () => { expect(registry.getMeta('msg-1')).toBeDefined(); - vi.advanceTimersByTime(10 * 60 * 1000); + vi.advanceTimersByTime(STREAM_MAX_LIFETIME_MS); expect(registry.getMeta('msg-1')).toBeUndefined(); expect(completedValues).toEqual([true]); }); - it('given a stream that finishes before 10 minutes, should not fire the auto-cleanup timer', () => { + it('given a stream that finishes inside the horizon, should not fire the auto-cleanup timer', () => { vi.useFakeTimers(); const registry = new StreamMulticastRegistry(); registry.register('msg-1', meta()); @@ -250,7 +255,7 @@ describe('StreamMulticastRegistry', () => { registry.finish('msg-1', false); - vi.advanceTimersByTime(10 * 60 * 1000); + vi.advanceTimersByTime(STREAM_MAX_LIFETIME_MS); expect(completedValues).toEqual([false]); }); @@ -267,7 +272,7 @@ describe('StreamMulticastRegistry', () => { expect(completed).toEqual([true]); }); - it('given register is called twice for the same messageId, should call onComplete only once after 10 minutes', () => { + it('given register is called twice for the same messageId, should call onComplete only once after the horizon', () => { vi.useFakeTimers(); const registry = new StreamMulticastRegistry(); registry.register('msg-1', meta()); @@ -276,7 +281,7 @@ describe('StreamMulticastRegistry', () => { const completed: boolean[] = []; registry.subscribe('msg-1', () => {}, (aborted) => completed.push(aborted)); - vi.advanceTimersByTime(10 * 60 * 1000); + vi.advanceTimersByTime(STREAM_MAX_LIFETIME_MS); expect(completed).toHaveLength(1); }); diff --git a/apps/web/src/lib/ai/core/__tests__/stream-subscription-authz.test.ts b/apps/web/src/lib/ai/core/__tests__/stream-subscription-authz.test.ts new file mode 100644 index 0000000000..d6fc32ad42 --- /dev/null +++ b/apps/web/src/lib/ai/core/__tests__/stream-subscription-authz.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const { mockSelectWhere, mockLimit } = vi.hoisted(() => ({ + mockSelectWhere: vi.fn(), + mockLimit: vi.fn(), +})); + +vi.mock('@pagespace/db/db', () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn((...args: unknown[]) => { + const result = mockSelectWhere(...args); + // `canSubscribeToStream` chains .limit(1); `filterSubscribableStreams` awaits directly. + return Object.assign(Promise.resolve(result), { limit: mockLimit }); + }), + })), + })), + }, +})); + +vi.mock('@pagespace/db/operators', () => ({ + and: vi.fn((...args: unknown[]) => ({ and: args })), + eq: vi.fn((col: unknown, val: unknown) => ({ eq: [col, val] })), + inArray: vi.fn((col: unknown, vals: unknown) => ({ inArray: [col, vals] })), +})); + +vi.mock('@pagespace/db/schema/conversations', () => ({ + conversations: { id: 'id', isShared: 'isShared' }, +})); + +import { canSubscribeToStream, filterSubscribableStreams } from '../stream-subscription-authz'; + +// A page channel carries EVERY conversation on that page, and conversations are private +// by default. Authorizing a stream subscription on page access alone therefore hands one +// member's private conversation — token by token, including its buffered parts snapshot — +// to every other member who can open the page. +describe('canSubscribeToStream', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSelectWhere.mockReturnValue([]); + mockLimit.mockResolvedValue([]); + }); + + it('given the caller started the stream, should allow without even looking up the conversation', async () => { + const allowed = await canSubscribeToStream({ + userId: 'user-1', + streamOwnerId: 'user-1', + conversationId: 'conv-1', + }); + + expect(allowed).toBe(true); + expect(mockLimit).not.toHaveBeenCalled(); + }); + + it("given another user's stream in a PRIVATE conversation, should deny", async () => { + mockLimit.mockResolvedValue([{ isShared: false }]); + + const allowed = await canSubscribeToStream({ + userId: 'user-b', + streamOwnerId: 'user-a', + conversationId: 'their-private-conv', + }); + + expect(allowed).toBe(false); + }); + + it("given another user's stream in an explicitly SHARED conversation, should allow", async () => { + mockLimit.mockResolvedValue([{ isShared: true }]); + + const allowed = await canSubscribeToStream({ + userId: 'user-b', + streamOwnerId: 'user-a', + conversationId: 'shared-conv', + }); + + expect(allowed).toBe(true); + }); + + // Fail closed: an unknown conversation is not a shared one. + it("given another user's stream whose conversation has no row, should deny", async () => { + mockLimit.mockResolvedValue([]); + + const allowed = await canSubscribeToStream({ + userId: 'user-b', + streamOwnerId: 'user-a', + conversationId: 'missing', + }); + + expect(allowed).toBe(false); + }); +}); + +describe('filterSubscribableStreams', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSelectWhere.mockReturnValue([]); + }); + + it('given only the caller\'s own streams, should return them all without a query', async () => { + const rows = [ + { userId: 'user-1', conversationId: 'c1', messageId: 'm1' }, + { userId: 'user-1', conversationId: 'c2', messageId: 'm2' }, + ]; + + const result = await filterSubscribableStreams({ userId: 'user-1', rows }); + + expect(result).toEqual(rows); + expect(mockSelectWhere).not.toHaveBeenCalled(); + }); + + it("given a mix, should keep own streams and shared ones and drop another user's private stream", async () => { + mockSelectWhere.mockReturnValue([{ id: 'shared-conv' }]); + const mine = { userId: 'user-1', conversationId: 'c1', messageId: 'mine' }; + const theirShared = { userId: 'user-2', conversationId: 'shared-conv', messageId: 'shared' }; + const theirPrivate = { userId: 'user-2', conversationId: 'private-conv', messageId: 'private' }; + + const result = await filterSubscribableStreams({ + userId: 'user-1', + rows: [mine, theirShared, theirPrivate], + }); + + expect(result).toEqual([mine, theirShared]); + }); + + it("given only another user's private streams, should return nothing", async () => { + mockSelectWhere.mockReturnValue([]); + + const result = await filterSubscribableStreams({ + userId: 'user-1', + rows: [{ userId: 'user-2', conversationId: 'private-conv', messageId: 'x' }], + }); + + expect(result).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/ai/core/__tests__/stream-takeover.test.ts b/apps/web/src/lib/ai/core/__tests__/stream-takeover.test.ts new file mode 100644 index 0000000000..fdc52c59c4 --- /dev/null +++ b/apps/web/src/lib/ai/core/__tests__/stream-takeover.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const { + mockSelectWhere, + mockUpdateWhere, + mockUpdateSet, + mockAbortStreamByMessageId, + mockLoggerInfo, + mockLoggerWarn, +} = vi.hoisted(() => ({ + mockLoggerInfo: vi.fn(), + mockLoggerWarn: vi.fn(), + mockSelectWhere: vi.fn(), + mockUpdateWhere: vi.fn().mockResolvedValue(undefined), + mockUpdateSet: vi.fn(), + mockAbortStreamByMessageId: vi.fn(), +})); + +vi.mock('@pagespace/db/db', () => ({ + db: { + select: vi.fn(() => ({ from: vi.fn(() => ({ where: mockSelectWhere })) })), + update: vi.fn(() => ({ set: mockUpdateSet })), + }, +})); + +vi.mock('@pagespace/db/operators', () => ({ + and: vi.fn((...args: unknown[]) => ({ and: args })), + eq: vi.fn((col: unknown, val: unknown) => ({ eq: [col, val] })), + inArray: vi.fn((col: unknown, vals: unknown) => ({ inArray: [col, vals] })), +})); + +vi.mock('@pagespace/db/schema/ai-streams', () => ({ + aiStreamSessions: { + messageId: 'messageId', + channelId: 'channelId', + conversationId: 'conversationId', + status: 'status', + parts: 'parts', + startedAt: 'startedAt', + lastHeartbeatAt: 'lastHeartbeatAt', + completedAt: 'completedAt', + }, +})); + +vi.mock('@pagespace/lib/logging/logger-config', () => ({ + loggers: { ai: { info: mockLoggerInfo, warn: mockLoggerWarn, error: vi.fn(), debug: vi.fn() } }, +})); + +vi.mock('@/lib/ai/core/stream-abort-registry', () => ({ + abortStreamByMessageId: mockAbortStreamByMessageId, +})); + +import { takeOverConversationStreams } from '../stream-takeover'; +import { inArray } from '@pagespace/db/operators'; + +const ARGS = { conversationId: 'conv-1', channelId: 'page-1' }; + +describe('takeOverConversationStreams', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }); + mockUpdateWhere.mockResolvedValue(undefined); + mockAbortStreamByMessageId.mockReturnValue({ aborted: true, reason: '' }); + }); + + it('given no in-flight stream on the conversation, should do nothing (the common case must be cheap)', async () => { + mockSelectWhere.mockResolvedValueOnce([]); + + const result = await takeOverConversationStreams(ARGS); + + expect(result).toEqual({ aborted: [], reconciled: [] }); + expect(mockAbortStreamByMessageId).not.toHaveBeenCalled(); + expect(mockUpdateSet).not.toHaveBeenCalled(); + }); + + // The bug this exists for: a second send used to simply start a SECOND generation on + // the same conversation. Two agents editing the same pages, two assistant rows, two + // bills. The only pre-existing limiter was the credit gate's per-USER maxInFlight. + it('given a live stream on the conversation, should abort it and drive its row terminal before the new generation starts', async () => { + mockSelectWhere.mockResolvedValueOnce([ + { messageId: 'msg-live', userId: 'user-1', lastHeartbeatAt: new Date(), startedAt: new Date() }, + ]); + + const result = await takeOverConversationStreams(ARGS); + + expect(mockAbortStreamByMessageId).toHaveBeenCalledWith({ messageId: 'msg-live', userId: 'user-1' }); + expect(result).toEqual({ aborted: ['msg-live'], reconciled: ['msg-live'] }); + expect(mockUpdateSet).toHaveBeenCalledWith( + expect.objectContaining({ status: 'aborted', parts: [], completedAt: expect.any(Date) }), + ); + }); + + // A crashed process leaves status='streaming' forever (the terminal write is + // fire-and-forget). A 409 here would lock the user out of their own conversation for + // as long as that row survives — strictly worse than the bug being fixed. + it('given a STALE streaming row (crashed process), should NOT block the send and should reconcile the dead row', async () => { + const longAgo = new Date(Date.now() - 30 * 60 * 1000); + mockAbortStreamByMessageId.mockReturnValue({ aborted: false, reason: 'Stream not found or already completed' }); + mockSelectWhere.mockResolvedValueOnce([ + { messageId: 'msg-dead', userId: 'user-1', lastHeartbeatAt: longAgo, startedAt: longAgo }, + ]); + + const result = await takeOverConversationStreams(ARGS); + + expect(result).toEqual({ aborted: [], reconciled: ['msg-dead'] }); + expect(mockUpdateSet).toHaveBeenCalledWith(expect.objectContaining({ status: 'aborted' })); + }); + + // A liveness guess must never gate the abort. The heartbeat can lag (a slow DB write, + // a GC pause), and skipping the abort for a row we wrongly called dead would leave a + // REAL generation running while we start a second one beside it — precisely what this + // guard exists to prevent. Aborting an unknown messageId is free. + it('given a row that looks stale, should STILL attempt the abort', async () => { + const longAgo = new Date(Date.now() - 30 * 60 * 1000); + mockSelectWhere.mockResolvedValueOnce([ + { messageId: 'msg-looks-dead', userId: 'user-1', lastHeartbeatAt: longAgo, startedAt: longAgo }, + ]); + + await takeOverConversationStreams(ARGS); + + expect(mockAbortStreamByMessageId).toHaveBeenCalledWith({ messageId: 'msg-looks-dead', userId: 'user-1' }); + }); + + // The row is beating, so the generation is alive — we simply cannot reach it (it runs + // on another web instance, or the registry refused a cross-user abort on a shared + // conversation). Marking it 'aborted' with parts=[] would hide a LIVE stream from + // every subscriber and destroy its only crash-recovery snapshot. + it('given a LIVE row the abort registry would not stop, should leave its row untouched rather than lie about it', async () => { + mockAbortStreamByMessageId.mockReturnValue({ aborted: false, reason: 'Stream not found or already completed' }); + mockSelectWhere.mockResolvedValueOnce([ + { messageId: 'msg-elsewhere', userId: 'user-1', lastHeartbeatAt: new Date(), startedAt: new Date() }, + ]); + + const result = await takeOverConversationStreams(ARGS); + + expect(result).toEqual({ aborted: [], reconciled: [] }); + expect(mockUpdateSet).not.toHaveBeenCalled(); + }); + + // On a SHARED conversation, user B sending must actually stop user A's generation. + // The abort registry authorizes aborts against the stream's OWNER (an IDOR guard for + // the client-facing /api/ai/abort endpoint), so issuing the abort as the caller would + // be refused every time — leaving A's generation running, still calling tools, still + // billing, while B's new one starts beside it. This is a trusted server path; the + // caller's right to write to the conversation was established upstream. + it("given a live stream owned by ANOTHER user, should abort it as its OWNER so the takeover actually stops it", async () => { + mockSelectWhere.mockResolvedValueOnce([ + { messageId: 'msg-other-user', userId: 'user-A', lastHeartbeatAt: new Date(), startedAt: new Date() }, + ]); + + const result = await takeOverConversationStreams(ARGS); + + expect(mockAbortStreamByMessageId).toHaveBeenCalledWith({ messageId: 'msg-other-user', userId: 'user-A' }); + expect(result.aborted).toEqual(['msg-other-user']); + expect(result.reconciled).toEqual(['msg-other-user']); + }); + + it('given the reconcile UPDATE, should be conditional on status=streaming so a stream that ended on its own is not relabelled', async () => { + mockSelectWhere.mockResolvedValueOnce([ + { messageId: 'msg-live', userId: 'user-1', lastHeartbeatAt: new Date(), startedAt: new Date() }, + ]); + + await takeOverConversationStreams(ARGS); + + const where = mockUpdateWhere.mock.calls[0][0] as { and: unknown[] }; + expect(where.and).toContainEqual({ eq: ['status', 'streaming'] }); + expect(vi.mocked(inArray)).toHaveBeenCalledWith('messageId', ['msg-live']); + }); + + // A failed takeover must degrade to the OLD behaviour (a concurrent generation), not + // to a chat the user cannot send in. + it('given the DB throws, should swallow it and let the send proceed', async () => { + mockSelectWhere.mockRejectedValueOnce(new Error('connection reset')); + + const result = await takeOverConversationStreams(ARGS); + + expect(result).toEqual({ aborted: [], reconciled: [] }); + }); + + // Partial failure. The aborts land FIRST — real in-process generations are stopped — and only + // then does the reconcile UPDATE run. A single try/catch around the whole function reported + // `{aborted: [], reconciled: []}` when that UPDATE threw: "nothing happened", while streams had + // in fact been stopped. The caller and the logs were told the exact opposite of the truth. + describe('partial failure: aborts landed but the reconcile UPDATE throws', () => { + it('reports what it ACTUALLY aborted, rather than claiming nothing happened', async () => { + mockSelectWhere.mockResolvedValue([ + { messageId: 'msg-live', userId: 'user-1', lastHeartbeatAt: new Date() }, + ]); + mockAbortStreamByMessageId.mockReturnValue({ aborted: true, reason: '' }); + mockUpdateWhere.mockRejectedValueOnce(new Error('db down')); + + const result = await takeOverConversationStreams({ + conversationId: 'conv-1', + channelId: 'page-1', + }); + + // The stream IS stopped. Saying otherwise is the bug. + expect(result.aborted).toEqual(['msg-live']); + // ...but its row was NOT driven terminal, and we must not pretend it was. + expect(result.reconciled).toEqual([]); + }); + + it('still does not block the send — a failed takeover must never lock the conversation', async () => { + mockSelectWhere.mockResolvedValue([ + { messageId: 'msg-live', userId: 'user-1', lastHeartbeatAt: new Date() }, + ]); + mockAbortStreamByMessageId.mockReturnValue({ aborted: true, reason: '' }); + mockUpdateWhere.mockRejectedValueOnce(new Error('db down')); + + await expect( + takeOverConversationStreams({ conversationId: 'conv-1', channelId: 'page-1' }), + ).resolves.toBeDefined(); + }); + + // The SELECT failing is different: nothing was stopped, so reporting nothing is correct. + it('given the SELECT itself fails, reports nothing aborted — because nothing was', async () => { + mockSelectWhere.mockRejectedValueOnce(new Error('db down')); + + const result = await takeOverConversationStreams({ + conversationId: 'conv-1', + channelId: 'page-1', + }); + + expect(result).toEqual({ aborted: [], reconciled: [] }); + }); + }); + + + // A log that misreports is a signal that attests to nothing — the same defect as a test that + // cannot fail. This one used to lie at exactly the moment an operator most needed the truth. + describe('what the logs actually claim', () => { + it('given a live stream on ANOTHER instance that could not be stopped, must NOT claim it took over', async () => { + // The abort registry is in-process. A row belonging to another web instance is live, cannot + // be aborted from here, and (having a fresh heartbeat) must not be reconciled either — so + // we took over NOTHING, and this send is about to start a SECOND generation beside it. + mockSelectWhere.mockResolvedValue([ + { messageId: 'msg-elsewhere', userId: 'user-1', lastHeartbeatAt: new Date() }, + ]); + mockAbortStreamByMessageId.mockReturnValue({ aborted: false, reason: 'not found' }); + + const result = await takeOverConversationStreams({ + conversationId: 'conv-1', + channelId: 'page-1', + }); + + expect(result).toEqual({ aborted: [], reconciled: [] }); + + // The warn tells the truth... + expect(mockLoggerWarn).toHaveBeenCalledWith( + expect.stringContaining('could not be aborted'), + expect.objectContaining({ messageIds: ['msg-elsewhere'] }), + ); + // ...and nothing may contradict it by claiming a takeover happened. + const claimedTakeover = mockLoggerInfo.mock.calls.some( + ([msg]) => typeof msg === 'string' && msg.includes('took over'), + ); + expect(claimedTakeover).toBe(false); + }); + + it('given a stream it DID stop, says so', async () => { + mockSelectWhere.mockResolvedValue([ + { messageId: 'msg-live', userId: 'user-1', lastHeartbeatAt: new Date() }, + ]); + mockAbortStreamByMessageId.mockReturnValue({ aborted: true, reason: '' }); + + await takeOverConversationStreams({ conversationId: 'conv-1', channelId: 'page-1' }); + + expect(mockLoggerInfo).toHaveBeenCalledWith( + expect.stringContaining('took over'), + expect.objectContaining({ aborted: ['msg-live'] }), + ); + }); + }); + +}); diff --git a/apps/web/src/lib/ai/core/abort-conversation-streams.ts b/apps/web/src/lib/ai/core/abort-conversation-streams.ts new file mode 100644 index 0000000000..ca710a037f --- /dev/null +++ b/apps/web/src/lib/ai/core/abort-conversation-streams.ts @@ -0,0 +1,81 @@ +import { db } from '@pagespace/db/db'; +import { and, eq } from '@pagespace/db/operators'; +import { aiStreamSessions } from '@pagespace/db/schema/ai-streams'; +import { abortStreamByMessageId } from './stream-abort-registry'; +import { loggers } from '@pagespace/lib/logging/logger-config'; + +/** + * Abort the caller's in-flight streams on a conversation, named by CONVERSATION rather than by + * streamId or messageId. + * + * This exists to close a window in which Stop did nothing at all. + * + * Both `streamId` and `messageId` are minted SERVER-side, and the client does not learn either + * until the response headers arrive (`X-Stream-Id`). But a real agent send spends 0.5-3 seconds + * before that — auth, rate limit, DB reads, context assembly, connecting to the provider. Press + * Stop in that window (exactly when a user who has spotted a typo does) and the client had no + * name for the stream: the abort was a guaranteed no-op, the local fetch was cancelled, and the + * button flipped back to Send. + * + * Streams are deliberately server-owned and survive a client disconnect — that is the entire + * architecture. So cancelling the fetch stops NOTHING: the generation kept running, kept calling + * write tools, and kept billing, while the UI told the user it had stopped. + * + * The conversationId is the one name the client holds from t=0. So Stop can now always say + * something true. + * + * AUTHORIZATION — deliberately stricter than the takeover's. + * + * `takeOverConversationStreams` aborts as the STREAM's owner (`row.userId`), because a second + * send on a SHARED conversation must be able to take over a co-member's generation. This is not + * that. This is an explicit user Stop, so it may only ever stop the caller's OWN streams: the + * `userId` filter is in the query, and the registry re-checks ownership on every abort. A user + * cannot stop someone else's generation by naming their conversation. + */ +export const abortConversationStreams = async ({ + conversationId, + userId, +}: { + conversationId: string; + userId: string; +}): Promise<{ aborted: string[]; reason: string }> => { + let rows: { messageId: string }[]; + try { + rows = await db + .select({ messageId: aiStreamSessions.messageId }) + .from(aiStreamSessions) + .where(and( + eq(aiStreamSessions.conversationId, conversationId), + // The caller's own streams only. See the authz note above. + eq(aiStreamSessions.userId, userId), + eq(aiStreamSessions.status, 'streaming'), + )); + } catch (error) { + loggers.ai.warn('abort-by-conversation: lookup failed', { + conversationId, + error: error instanceof Error ? error.message : 'unknown', + }); + return { aborted: [], reason: 'Lookup failed' }; + } + + const aborted: string[] = []; + for (const row of rows) { + // Registry re-checks ownership; passing the caller's id (not the row's) is the point. + const result = abortStreamByMessageId({ messageId: row.messageId, userId }); + if (result.aborted) aborted.push(row.messageId); + } + + if (aborted.length === 0) { + // Not an error. The stream may live on another web instance (the abort registry is + // in-process), or it may have finished between the SELECT and here. Reported honestly + // rather than as a success. + return { + aborted: [], + reason: rows.length > 0 + ? 'In-flight stream(s) found but none could be aborted from this instance' + : 'No in-flight stream on this conversation', + }; + } + + return { aborted, reason: '' }; +}; diff --git a/apps/web/src/lib/ai/core/client.ts b/apps/web/src/lib/ai/core/client.ts index f4a8c4e61e..bdb1c592fb 100644 --- a/apps/web/src/lib/ai/core/client.ts +++ b/apps/web/src/lib/ai/core/client.ts @@ -7,6 +7,7 @@ export { abortActiveStream, + abortActiveStreamByConversation, abortActiveStreamByMessageId, createStreamTrackingFetch, setActiveStreamId, diff --git a/apps/web/src/lib/ai/core/stream-abort-client.ts b/apps/web/src/lib/ai/core/stream-abort-client.ts index ff8911eadc..b841a49cfc 100644 --- a/apps/web/src/lib/ai/core/stream-abort-client.ts +++ b/apps/web/src/lib/ai/core/stream-abort-client.ts @@ -13,6 +13,10 @@ import { fetchWithAuth } from '@/lib/auth/auth-fetch'; import { getBrowserSessionId } from './browser-session-id'; +import { + markChannelConsuming, + unmarkChannelConsuming, +} from '@/lib/ai/streams/consumingChannels'; // Track active streams by chat/conversation ID const activeStreams = new Map(); @@ -55,14 +59,59 @@ export const clearActiveStreamId = ({ chatId }: { chatId: string }): void => { * Abort an active stream by calling the server-side abort endpoint * Returns true if abort was requested, false if no active stream */ +/** + * Abort by CONVERSATION — the only name the client holds from t=0. + * + * Both `streamId` and `messageId` are minted server-side, and the client does not learn either + * until the response headers land. A real agent send spends 0.5-3 seconds before that (auth, + * rate limit, DB reads, context assembly, connecting to the provider). Stop pressed in that + * window — precisely when a user who has spotted a typo presses it — had NOTHING to name: the + * `activeStreams` map was empty, the abort was a guaranteed no-op, the local fetch was cancelled, + * and the button flipped back to Send. + * + * And cancelling the fetch stops nothing. Streams are deliberately server-owned and survive a + * client disconnect — that is the whole architecture. So the generation kept running, kept + * calling write tools, and kept BILLING, while the UI told the user it had stopped. + * + * This is the fallback that makes Stop always able to say something true. Server-side it only + * ever stops the caller's OWN streams (see abort-conversation-streams.ts). + */ +export const abortActiveStreamByConversation = async ({ + conversationId, +}: { + conversationId: string; +}): Promise<{ aborted: boolean; reason: string }> => { + try { + const response = await fetchWithAuth('/api/ai/abort', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ conversationId }), + }); + return await response.json(); + } catch { + return { aborted: false, reason: 'Failed to call abort endpoint' }; + } +}; + export const abortActiveStream = async ({ chatId, + /** + * The conversation this chat is streaming, when known. Used ONLY as a fallback: if the + * activeStreams map has no entry — which is the norm before the response headers land — we + * still have a name for the stream, and Stop must not silently no-op. See + * abortActiveStreamByConversation. + */ + conversationId, }: { chatId: string; + conversationId?: string | null; }): Promise<{ aborted: boolean; reason: string }> => { const streamId = activeStreams.get(chatId); if (!streamId) { + if (conversationId) { + return abortActiveStreamByConversation({ conversationId }); + } return { aborted: false, reason: 'No active stream for this chat' }; } @@ -112,29 +161,129 @@ export const abortActiveStreamByMessageId = async ({ } }; +/** + * Re-wraps a streaming response so `onDone` fires exactly once, when the body is + * genuinely finished — closed, errored, or cancelled. `fetch` resolving only tells + * us the headers landed; the tokens are still arriving after that. + */ +const withBodyCompletion = (response: Response, onDone: () => void): Response => { + const body = response.body; + if (!body) { + onDone(); + return response; + } + + let done = false; + const finishOnce = () => { + if (done) return; + done = true; + onDone(); + }; + + const reader = body.getReader(); + const tracked = new ReadableStream({ + async pull(controller) { + try { + const { done: streamDone, value } = await reader.read(); + if (streamDone) { + finishOnce(); + controller.close(); + return; + } + controller.enqueue(value); + } catch (error) { + finishOnce(); + controller.error(error); + } + }, + cancel(reason) { + finishOnce(); + return reader.cancel(reason); + }, + }); + + return new Response(tracked, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +}; + /** * Create a fetch wrapper that tracks streamId from response headers * Use this with DefaultChatTransport + * + * `channelId` (the socket room the server broadcasts this stream's lifecycle on — + * NOT the same value as `chatId`, which is a transport-local key) is marked as + * "being consumed by this browser context" for exactly as long as this client is + * reading tokens off the response body. That is the one signal that makes the + * client's own `chat:stream_start` uninteresting; without it we'd render every + * token twice. See `consumingChannels.ts` for why this replaces the old + * `browserSessionId` check. */ export const createStreamTrackingFetch = ({ - chatId, + getChatId, + getChannelId, }: { - chatId: string; + /** + * Resolved AT CALL TIME, not at construction. This is not a style choice. + * + * useChat only rebuilds its `Chat` when its `id` changes (@ai-sdk/react: + * `shouldRecreateChat`), and every surface here passes a CONSTANT id. The `Chat` binds + * `this.transport` once in its constructor and calls `this.transport.sendMessages()` + * forever after — so the first transport a surface ever builds is the only one it will + * ever use, and every later one is constructed and thrown away. + * + * Baking `chatId` into this closure therefore froze it at whatever conversation the surface + * happened to start with. After a single conversation switch the map was WRITTEN under the + * old id and READ under the new one: `abortActiveStream({chatId})` was a guaranteed miss, + * the local fetch stopped, and the server kept generating and kept billing. Same for + * `channelId` after an agent switch: the wrong channel got marked as consuming, so the tab + * failed to recognise its OWN stream on the socket, joined its own multicast, and rendered + * the reply twice. + * + * The server already compensates for this freeze on the URL side (see the note in + * /api/ai/global/[id]/messages — the id segment is likewise baked in and never updates). + * Nothing had propagated that lesson to the tracking keys. + */ + getChatId: () => string | null; + getChannelId: () => string | undefined; }): typeof fetch => { return async (url, options) => { + const chatId = getChatId(); + const channelId = getChannelId(); const urlString = url instanceof Request ? url.url : url.toString(); const merged = new Headers(options?.headers); merged.set('X-Browser-Session-Id', getBrowserSessionId()); const headers = Object.fromEntries(merged.entries()); - const response = await fetchWithAuth(urlString, { ...options, headers }); + + // Marked BEFORE the request leaves, so it can never lose the race against the + // server's broadcastAiStreamStart (which is why this is not derived from the + // X-Stream-Id response header). + if (channelId) markChannelConsuming(channelId); + + let response: Response; + try { + response = await fetchWithAuth(urlString, { ...options, headers }); + } catch (error) { + if (channelId) unmarkChannelConsuming(channelId); + throw error; + } // Extract streamId from response headers (for global assistant route) const streamId = response.headers.get(STREAM_ID_HEADER); - if (streamId) { + if (streamId && chatId) { setActiveStreamId({ chatId, streamId }); } - return response; + if (!channelId) return response; + if (!response.ok) { + unmarkChannelConsuming(channelId); + return response; + } + + const consumedChannelId = channelId; + return withBodyCompletion(response, () => unmarkChannelConsuming(consumedChannelId)); }; }; diff --git a/apps/web/src/lib/ai/core/stream-abort-registry.ts b/apps/web/src/lib/ai/core/stream-abort-registry.ts index bdde360707..0e628908de 100644 --- a/apps/web/src/lib/ai/core/stream-abort-registry.ts +++ b/apps/web/src/lib/ai/core/stream-abort-registry.ts @@ -13,6 +13,7 @@ */ import { createId } from '@paralleldrive/cuid2'; +import { STREAM_MAX_LIFETIME_MS } from '@/lib/ai/core/stream-horizons'; interface StreamEntry { controller: AbortController; @@ -47,8 +48,11 @@ const unlinkStream = (streamId: string): void => { messageIdToStreamId.delete(messageId); }; -// Cleanup streams older than 10 minutes (safety net for orphaned entries) -const MAX_STREAM_AGE_MS = 10 * 60 * 1000; +// Safety net for orphaned entries only — a stream that ends normally unregisters itself. +// Shares STREAM_MAX_LIFETIME_MS with the multicast registry and the heartbeat cap: if this +// were shorter, a still-running long generation would be reported as live by +// /active-streams while its Stop button had already become a no-op here. +const MAX_STREAM_AGE_MS = STREAM_MAX_LIFETIME_MS; const CLEANUP_INTERVAL_MS = 60 * 1000; let cleanupInterval: ReturnType | null = null; diff --git a/apps/web/src/lib/ai/core/stream-horizons.ts b/apps/web/src/lib/ai/core/stream-horizons.ts new file mode 100644 index 0000000000..bc213b98c4 --- /dev/null +++ b/apps/web/src/lib/ai/core/stream-horizons.ts @@ -0,0 +1,22 @@ +/** + * The one place that decides how long a stream may plausibly still be running. + * + * Three independent mechanisms used to answer this question, and they disagreed: + * + * - the heartbeat cap in `stream-lifecycle.ts` (how long a generation keeps declaring + * itself alive), + * - the abort registry's eviction (how long a stream can still be STOPPED), + * - the multicast registry's eviction (how long a stream can still be JOINED). + * + * When they disagree, the system lies. With the registries at 10 minutes and the heartbeat + * at an hour, a deep-research or long tool-loop generation still alive at minute 15 was + * correctly reported as running by `/active-streams` — and every client rendered it with a + * Stop button that could not abort it (the abort entry was gone) and could not stream its + * tokens (the multicast entry was gone). A live stream you cannot watch and cannot stop. + * + * So they share one number. Eviction here is a leak backstop, not a policy: a stream that + * ends normally removes its own entries in `finish()`, so the only things this reclaims are + * generations whose process died — and holding those a little longer costs nothing next to + * shipping a Stop button that silently does nothing. + */ +export const STREAM_MAX_LIFETIME_MS = 60 * 60 * 1000; diff --git a/apps/web/src/lib/ai/core/stream-lifecycle.ts b/apps/web/src/lib/ai/core/stream-lifecycle.ts index 9fd4e30051..e9b3cba936 100644 --- a/apps/web/src/lib/ai/core/stream-lifecycle.ts +++ b/apps/web/src/lib/ai/core/stream-lifecycle.ts @@ -2,6 +2,7 @@ import { db } from '@pagespace/db/db'; import { eq } from '@pagespace/db/operators'; import { aiStreamSessions } from '@pagespace/db/schema/ai-streams'; import { loggers } from '@pagespace/lib/logging/logger-config'; +import { STREAM_MAX_LIFETIME_MS } from '@/lib/ai/core/stream-horizons'; import { broadcastAiStreamStart, broadcastAiStreamComplete } from '@/lib/websocket'; import { streamMulticastRegistry, @@ -15,6 +16,12 @@ export interface StreamLifecycleParams { userId: string; displayName: string; browserSessionId: string; + /** + * Whether the conversation is explicitly shared. Rides the stream_start broadcast so + * page members can tell, without asking, whether a stream is theirs to watch — see + * AiStreamStartPayload.isShared. + */ + isShared?: boolean; } export interface StreamLifecycleHandle { @@ -28,10 +35,54 @@ export interface StreamLifecycleHandle { // keeping write amplification low. const PERSIST_EVERY_N_PARTS = 20; +/** + * How often the generation writes `lastHeartbeatAt`. + * + * This is a real timer, and it has to be: the parts checkpoint above cannot serve as + * a heartbeat, because a stream sitting in a long tool call (sandbox exec, deep + * research, a slow MCP tool) pushes NO parts for minutes at a time. Riding the + * checkpoint would declare a perfectly healthy stream dead — it would disappear from + * `/active-streams` so no client could attach, and the next send would fail to abort + * it and would generate alongside it. + * + * Comfortably several beats inside STREAM_HEARTBEAT_STALE_MS, and it is one tiny + * single-row UPDATE per interval per in-flight stream. + */ +const HEARTBEAT_INTERVAL_MS = 20 * 1000; + +/** + * Hard ceiling on how long a lifecycle will keep beating. + * + * A backstop, not a policy. `finish()` clears the interval, and every generation path + * reaches it — but if one ever did not, an unbounded heartbeat would be strictly worse + * than no heartbeat: the row would look *live forever*, so it could never be reconciled, + * could never be taken over (the abort registry evicts its entry after MAX_STREAM_AGE_MS + * = 10 min, after which the abort is a no-op), and would be served to clients as an + * unjoinable phantom stream for the life of the process. Capping the beat converts that + * immortal ghost back into an ordinary stale row, which the next takeover reconciles. + * + * Shares STREAM_MAX_LIFETIME_MS with the abort and multicast registries — deliberately, so + * the three cannot drift apart again. When they disagreed (registries at 10 minutes, this + * at an hour), a long generation still alive at minute 15 was correctly reported as running + * while no client could join it and its Stop button had already become a no-op. + * + * A generation that outlives the cap stops beating while still alive, and ~2 minutes later + * its row reads as stale — so the next send on that conversation would drive a LIVE row + * terminal, the lie `decideStreamTakeover` exists to avoid. An hour buys enough headroom + * that the trade is academic, while still bounding a leaked interval. + * + * The parts checkpoint in `pushPart` obeys this same deadline, and MUST. It writes + * lastHeartbeatAt too, so an uncapped checkpoint let any still-chattering generation refresh + * its own liveness forever — reinstating the immortal ghost this cap exists to kill, on the + * one stream most likely to hit it. (An earlier version of this comment had it exactly + * backwards, calling the checkpoint beat a mitigation. It was the hole.) + */ +const MAX_HEARTBEAT_MS = STREAM_MAX_LIFETIME_MS; + export const createStreamLifecycle = async ( params: StreamLifecycleParams, ): Promise => { - const { messageId, channelId, conversationId, userId, displayName, browserSessionId } = params; + const { messageId, channelId, conversationId, userId, displayName, browserSessionId, isShared } = params; try { streamMulticastRegistry.register(messageId, { @@ -64,6 +115,7 @@ export const createStreamLifecycle = async ( browserSessionId, status: 'streaming', startedAt, + lastHeartbeatAt: startedAt, }) .onConflictDoUpdate({ target: aiStreamSessions.messageId, @@ -75,6 +127,7 @@ export const createStreamLifecycle = async ( browserSessionId, status: 'streaming', startedAt, + lastHeartbeatAt: startedAt, completedAt: null, // A re-registered messageId gets a fresh (empty) in-memory buffer // above — the DB snapshot must reset with it, or a bootstrap @@ -95,6 +148,7 @@ export const createStreamLifecycle = async ( pageId: channelId, conversationId, startedAt: startedAt.toISOString(), + isShared: isShared === true, triggeredBy: { userId, displayName, browserSessionId }, }).catch(() => {}); @@ -110,7 +164,7 @@ export const createStreamLifecycle = async ( try { await db .update(aiStreamSessions) - .set({ parts }) + .set({ parts, lastHeartbeatAt: new Date() }) .where(eq(aiStreamSessions.messageId, messageId)); } catch (error) { loggers.ai.warn('stream-lifecycle: aiStreamSessions parts persist failed', { @@ -126,9 +180,34 @@ export const createStreamLifecycle = async ( return attempt; }; + // Liveness beat. Independent of pushPart on purpose — see HEARTBEAT_INTERVAL_MS. + // Touches only lastHeartbeatAt, so it can never race the parts writes (and a tick that + // lands after the terminal write cannot resurrect the row: every reader filters + // status='streaming'). + const heartbeatDeadline = startedAt.getTime() + MAX_HEARTBEAT_MS; + const heartbeat = setInterval(() => { + if (finished || Date.now() > heartbeatDeadline) { + clearInterval(heartbeat); + return; + } + void db + .update(aiStreamSessions) + .set({ lastHeartbeatAt: new Date() }) + .where(eq(aiStreamSessions.messageId, messageId)) + .catch((error: unknown) => { + loggers.ai.warn('stream-lifecycle: heartbeat write failed', { + messageId, + error: error instanceof Error ? error.message : 'unknown', + }); + }); + }, HEARTBEAT_INTERVAL_MS); + // Never hold the process open for a heartbeat. + heartbeat.unref?.(); + const finish = (aborted: boolean): void => { if (finished) return; finished = true; + clearInterval(heartbeat); const priorPersist = persistInFlight; @@ -196,6 +275,23 @@ export const createStreamLifecycle = async ( partsSincePersist += 1; if (partsSincePersist >= PERSIST_EVERY_N_PARTS && !persistInFlight) { partsSincePersist = 0; + + // The checkpoint obeys the SAME horizon as the interval beat. It did not, and that made + // it the exact hole MAX_HEARTBEAT_MS exists to close rather than (as the docblock above + // used to claim) a mitigation for it: `persistBufferedParts` also writes lastHeartbeatAt, + // so any generation still pushing parts past the horizon refreshed its own liveness + // FOREVER. Both registries have already evicted it by then — its Stop button is a silent + // no-op and no client can join it — while /active-streams went on serving the row as a + // live, joinable stream. An immortal ghost, which is precisely what the cap was for. + if (Date.now() > heartbeatDeadline) return; + + // The registry entry is the source of the snapshot. Once it is gone — evicted at the + // horizon, or deleted by a finish() that raced us — `getBufferedParts` returns `[]` + // meaning "NO ENTRY", not "no content". Serializing that would overwrite the real parts + // snapshot with nothing, destroying exactly the crash-recovery state it exists to provide: + // a client restoring mid-stream content after the originator's process dies. + if (streamMulticastRegistry.getMeta(messageId) === undefined) return; + persistBufferedParts(streamMulticastRegistry.getBufferedParts(messageId)); } }; diff --git a/apps/web/src/lib/ai/core/stream-liveness.ts b/apps/web/src/lib/ai/core/stream-liveness.ts new file mode 100644 index 0000000000..44a9117c8e --- /dev/null +++ b/apps/web/src/lib/ai/core/stream-liveness.ts @@ -0,0 +1,112 @@ +/** + * Liveness and takeover decisions for server-owned streams. + * + * Pure functions — no DB, no I/O. The callers (the takeover guard on both chat routes, + * and GET /api/ai/chat/active-streams) supply the rows and the clock. + * + * WHY LIVENESS IS A HEARTBEAT AND NOT A STATUS + * + * `ai_stream_sessions.status` cannot be trusted on its own. The terminal write + * that flips it off `'streaming'` is fire-and-forget (see `stream-lifecycle.ts`) + * and dies with the process, so a crashed or redeployed generation leaves a row + * that says `'streaming'` forever. Anything that *blocks* on such a row (a 409 + * on concurrent send, say) would lock the user out of their own conversation. + * So we carry `lastHeartbeatAt`, written on a dedicated interval by the generation + * itself, and treat a row with a stale heartbeat as dead. + * + * Liveness gates only what may be driven TERMINAL. It must never gate an abort — see + * `decideStreamTakeover` below. + */ + +/** + * A row is live if it has beaten recently. + * + * The heartbeat is written on a fixed interval by the generation itself (see + * HEARTBEAT_INTERVAL_MS in stream-lifecycle.ts) — deliberately NOT off the + * parts checkpoint. A stream sitting in a long tool call (sandbox exec, deep + * research, a slow MCP tool) pushes no parts at all for minutes, so a + * checkpoint-driven heartbeat would declare a perfectly healthy stream dead: it + * would vanish from `/active-streams` (no client could attach to it) and the next + * send would fail to stop it. + * + * The window below is several missed beats wide, so an ordinary GC pause or a slow + * DB write never trips it, while a crashed process stops being served as a phantom + * "streaming" ghost within a couple of minutes. + */ +export const STREAM_HEARTBEAT_STALE_MS = 2 * 60 * 1000; + +export interface StreamLivenessRow { + messageId: string; + lastHeartbeatAt: Date | null; + startedAt: Date; +} + +export const isStreamRowLive = ( + row: StreamLivenessRow, + now: number, + staleAfterMs: number = STREAM_HEARTBEAT_STALE_MS, +): boolean => { + // The column is NOT NULL DEFAULT now(), so this fallback is defensive only — it exists + // so a caller that projects the column loosely (or a future nullable variant) degrades + // to "aged from startedAt" rather than to a crash on `null.getTime()`. + const beat = row.lastHeartbeatAt ?? row.startedAt; + return now - beat.getTime() < staleAfterMs; +}; + +export interface StreamTakeoverDecision { + /** + * Streams to attempt to abort. This is EVERY row, live-looking or not. + * + * Aborting is free for a messageId the in-process registry doesn't know + * (`abortStreamByMessageId` returns `{aborted:false}` — it does not throw), while + * *skipping* the abort for a row we misjudged as dead leaves a real generation + * running and starts a second one alongside it. The asymmetry is total, so we + * never let a liveness guess gate the abort. + */ + abort: string[]; + /** + * Rows to drive terminal. ONLY the rows we can prove are finished: the ones the + * abort registry actually stopped, plus the ones whose heartbeat says the process + * that owned them is gone. + * + * A row we could NOT abort and that still looks alive is deliberately left alone. + * Writing `status='aborted', parts=[]` over a still-generating stream would be a + * lie with teeth: it hides the stream from `/active-streams` (so no client can + * attach to it) and destroys the parts snapshot that is its only crash-recovery + * copy — while the generation keeps running, keeps calling tools, and keeps + * billing. + */ + reconcile: string[]; +} + +/** + * TAKEOVER, NOT 409. + * + * A second send on a conversation that already has an in-flight stream must end with + * exactly one generation running — but rejecting the send is the wrong way to get + * there. It would self-lock the conversation behind any row whose terminal write + * never landed (see above). So instead: try to abort everything in flight, drive + * terminal what we provably stopped or that is provably dead, and proceed. + * + * Call this AFTER attempting the aborts, so the outcome of each abort is known. + */ +export const decideStreamTakeover = ({ + rows, + abortedMessageIds, + now, + staleAfterMs = STREAM_HEARTBEAT_STALE_MS, +}: { + rows: StreamLivenessRow[]; + /** messageIds the in-process abort registry reported it actually aborted. */ + abortedMessageIds?: readonly string[]; + now: number; + staleAfterMs?: number; +}): StreamTakeoverDecision => { + const aborted = new Set(abortedMessageIds ?? []); + return { + abort: rows.map((r) => r.messageId), + reconcile: rows + .filter((r) => aborted.has(r.messageId) || !isStreamRowLive(r, now, staleAfterMs)) + .map((r) => r.messageId), + }; +}; diff --git a/apps/web/src/lib/ai/core/stream-multicast-registry.ts b/apps/web/src/lib/ai/core/stream-multicast-registry.ts index 54c0b292b8..1a32c68e61 100644 --- a/apps/web/src/lib/ai/core/stream-multicast-registry.ts +++ b/apps/web/src/lib/ai/core/stream-multicast-registry.ts @@ -1,6 +1,11 @@ import type { UIMessage } from 'ai'; +import { STREAM_MAX_LIFETIME_MS } from '@/lib/ai/core/stream-horizons'; -const MAX_STREAM_AGE_MS = 10 * 60 * 1000; +// Hard cap on how long an entry survives without finish() — a leak backstop, not a policy. +// Shares STREAM_MAX_LIFETIME_MS with the abort registry and the heartbeat cap: if this were +// shorter, a still-running long generation would be reported as live by /active-streams +// while no client could join it any more. +const MAX_STREAM_AGE_MS = STREAM_MAX_LIFETIME_MS; export type UIMessagePart = UIMessage['parts'][number]; diff --git a/apps/web/src/lib/ai/core/stream-subscription-authz.ts b/apps/web/src/lib/ai/core/stream-subscription-authz.ts new file mode 100644 index 0000000000..790477b205 --- /dev/null +++ b/apps/web/src/lib/ai/core/stream-subscription-authz.ts @@ -0,0 +1,69 @@ +import { db } from '@pagespace/db/db'; +import { and, eq, inArray } from '@pagespace/db/operators'; +import { conversations } from '@pagespace/db/schema/conversations'; + +/** + * Who may SUBSCRIBE to a server-owned stream. + * + * Page access is not the right question. A page channel carries every conversation on + * that page, and conversations are PRIVATE by default — so authorizing a stream + * subscription on "can this user view the page" hands one member's private conversation, + * token by token, to every other member who opens it. That was true of both + * `/api/ai/chat/active-streams` (which also returns the buffered `parts` snapshot) and + * `/api/ai/chat/stream-join/[messageId]`. + * + * The right question is the conversation's. You may subscribe to a stream when: + * - you started it (the stream row carries its owner), or + * - its conversation is explicitly shared. + * + * Fails closed: a conversation with no row is not shared, so a non-owner gets nothing. + * Page-level authorization still runs first at each call site — this narrows it, it does + * not replace it. + */ +export const canSubscribeToStream = async ({ + userId, + streamOwnerId, + conversationId, +}: { + userId: string; + streamOwnerId: string; + conversationId: string; +}): Promise => { + if (streamOwnerId === userId) return true; + + const [conversation] = await db + .select({ isShared: conversations.isShared }) + .from(conversations) + .where(eq(conversations.id, conversationId)) + .limit(1); + + return conversation?.isShared === true; +}; + +/** + * Batched form of `canSubscribeToStream` for the active-streams listing: returns the + * subset of `rows` this user may subscribe to. One query regardless of row count. + */ +export const filterSubscribableStreams = async < + T extends { userId: string; conversationId: string }, +>({ + userId, + rows, +}: { + userId: string; + rows: T[]; +}): Promise => { + const foreign = rows.filter((r) => r.userId !== userId); + if (foreign.length === 0) return rows; + + const sharedIds = await db + .select({ id: conversations.id }) + .from(conversations) + .where(and( + inArray(conversations.id, [...new Set(foreign.map((r) => r.conversationId))]), + eq(conversations.isShared, true), + )); + + const shared = new Set(sharedIds.map((c) => c.id)); + return rows.filter((r) => r.userId === userId || shared.has(r.conversationId)); +}; diff --git a/apps/web/src/lib/ai/core/stream-takeover.ts b/apps/web/src/lib/ai/core/stream-takeover.ts new file mode 100644 index 0000000000..76188a3b4d --- /dev/null +++ b/apps/web/src/lib/ai/core/stream-takeover.ts @@ -0,0 +1,194 @@ +import { db } from '@pagespace/db/db'; +import { and, eq, inArray } from '@pagespace/db/operators'; +import { aiStreamSessions } from '@pagespace/db/schema/ai-streams'; +import { loggers } from '@pagespace/lib/logging/logger-config'; +import { abortStreamByMessageId } from '@/lib/ai/core/stream-abort-registry'; +import { decideStreamTakeover } from '@/lib/ai/core/stream-liveness'; + +/** + * Per-conversation in-flight guard. Used by both chat routes (POST /api/ai/chat and + * POST /api/ai/global/[id]/messages) — every path that starts a generation. + * + * ── WHAT THIS DOES NOT GUARANTEE ──────────────────────────────────────────────────────────── + * + * It does NOT enforce "at most one generation per conversation", and you must not write code + * that assumes it does. This is a check-then-act with no serialization: the SELECT below and the + * INSERT in `createStreamLifecycle` — which is what would make a peer see this generation — are + * not atomic together. Two near-simultaneous sends can BOTH find zero in-flight rows and BOTH + * proceed: two generations, two sets of tool calls, two bills. + * + * Closing it needs DB-level serialization: an advisory lock spanning takeover+insert, or a + * partial unique index on (conversation_id) WHERE status='streaming' — whose migration would + * fail outright on any pre-existing duplicate rows, so it needs a reconciliation step first. + * That is its own change, with its own migration risk. `master` has no takeover at all + * (concurrent sends there ALWAYS double-generate), so what follows is a strict improvement — it + * narrows the window, it does not eliminate it. + * + * Stated here because the call site's comment used to claim the opposite, and a comment that + * promises a guarantee the code lacks is how the next person builds on a false premise. + * + * ── WHAT IT DOES ──────────────────────────────────────────────────────────────────────────── + * + * Before a new generation starts on a conversation, anything already in flight on that + * conversation is taken over: + * + * 1. EVERY in-flight row gets an abort attempt, as the stream's OWNER. Liveness never + * gates this: aborting a messageId this process doesn't know is free, while skipping + * an abort for a row we misjudged as dead leaves a real generation running. + * 2. Only rows we can PROVE are finished — the ones the registry actually aborted, plus + * the ones whose heartbeat says their process is gone — are driven terminal. A live + * row we could not abort (it belongs to another web instance) is left alone: marking + * it 'aborted' and wiping its parts would hide a running stream from every subscriber + * and destroy its only crash-recovery snapshot. + * + * The new generation then proceeds regardless — see `stream-liveness.ts` for why this is a + * takeover and never a 409. + * + * Without this, a second send simply starts a second generation on the same conversation — + * two agents editing the same pages, two assistant rows, two bills. (The only pre-existing + * limiter is the credit gate's per-USER `maxInFlight`, which is per-user, not + * per-conversation, and is skipped entirely for metering-exempt providers.) + */ +export const takeOverConversationStreams = async ({ + conversationId, + channelId, + now = Date.now(), +}: { + conversationId: string; + channelId: string; + now?: number; +}): Promise<{ aborted: string[]; reconciled: string[] }> => { + try { + const rows = await db + .select({ + messageId: aiStreamSessions.messageId, + // The stream's OWNER — not the caller. See the abort loop below. + userId: aiStreamSessions.userId, + lastHeartbeatAt: aiStreamSessions.lastHeartbeatAt, + startedAt: aiStreamSessions.startedAt, + }) + .from(aiStreamSessions) + .where(and( + eq(aiStreamSessions.conversationId, conversationId), + eq(aiStreamSessions.channelId, channelId), + eq(aiStreamSessions.status, 'streaming'), + )); + + if (rows.length === 0) return { aborted: [], reconciled: [] }; + + // Abort EVERY in-flight row, without consulting liveness. An abort for a messageId + // this process doesn't own is a no-op that returns `{aborted:false}`; SKIPPING an + // abort for a row we wrongly judged dead leaves a real generation running and starts + // a second one beside it. Only the outcome of these calls is trustworthy — never our + // liveness guess. (`decideStreamTakeover().abort` encodes exactly this set: every + // row. Iterating `rows` here is the same thing, and keeps the owner id in hand.) + // + // Abort as the stream's OWNER, not as the caller. `abortStream` refuses an abort + // whose userId doesn't match the stream's — an IDOR guard that exists for the + // client-facing POST /api/ai/abort endpoint. This is not that: it is a trusted + // server-side path, and the caller's right to write to this conversation was already + // established upstream (page edit access, plus the ownership/shared check on + // conversationId in the route; the global route hard-throws on a foreign + // conversation). Passing the caller's id would make the guard refuse every + // cross-user abort, so on a SHARED conversation user B's send would leave user A's + // generation running — still calling tools, still editing pages, still billing — + // while B's new one started beside it. Which is the entire thing this guard exists + // to prevent. + const aborted: string[] = []; + for (const row of rows) { + const result = abortStreamByMessageId({ messageId: row.messageId, userId: row.userId }); + if (result.aborted) aborted.push(row.messageId); + } + + // Only NOW decide what may be driven terminal: what we actually stopped, plus what is + // provably dead. A row we could NOT abort and that still looks alive belongs to + // another web instance (the registry is single-process). It is still generating — + // marking it 'aborted' and wiping its parts would hide a live stream from every + // subscriber and destroy its only crash-recovery snapshot. + const { reconcile } = decideStreamTakeover({ rows, abortedMessageIds: aborted, now }); + + if (reconcile.length > 0) { + // Its OWN catch, deliberately. + // + // By this point the aborts above have already landed: real in-process generations have + // been STOPPED. If this UPDATE then throws and the outer catch swallows it, we return + // `{aborted: [], reconciled: []}` — "nothing happened" — which is a lie. Streams were + // stopped, and their rows are still `status='streaming'`, so every reader treats them as + // live: /active-streams advertises them, clients render a Stop button for a generation that + // is already dead. The one thing the caller must be told accurately is what was actually + // aborted, and the old shape guaranteed it would be told the opposite. + // + // The rows self-heal: a stopped generation stops beating, so within + // STREAM_HEARTBEAT_STALE_MS the liveness predicate calls them dead, /active-streams stops + // serving them, and the next takeover reconciles them. That is exactly the failure mode the + // heartbeat exists to absorb — so the right move is to report the truth and let it, not to + // fail the send. + try { + // Conditional on status so a stream that terminated on its own between the + // SELECT and here isn't retroactively relabelled 'aborted'. + await db + .update(aiStreamSessions) + .set({ status: 'aborted', completedAt: new Date(), parts: [] }) + .where(and( + inArray(aiStreamSessions.messageId, reconcile), + eq(aiStreamSessions.status, 'streaming'), + )); + } catch (error) { + loggers.ai.warn('AI Chat API: takeover aborted streams but could not reconcile their rows', { + conversationId, + channelId, + // The streams ARE stopped. These rows will read 'streaming' until their heartbeat + // goes stale (~2 min), then be reconciled by the next takeover. + aborted, + unreconciled: reconcile, + error: error instanceof Error ? error.message : 'unknown', + }); + return { aborted, reconciled: [] }; + } + } + + const unstoppable = rows + .map((r) => r.messageId) + .filter((id) => !aborted.includes(id) && !reconcile.includes(id)); + if (unstoppable.length > 0) { + // Known limitation, logged rather than papered over: the abort registry is + // in-process, so a live stream owned by another web instance cannot be stopped + // from here. It will finish and persist normally; this send runs alongside it. + loggers.ai.warn('AI Chat API: in-flight stream(s) could not be aborted from this instance', { + conversationId, + channelId, + messageIds: unstoppable, + }); + } + + // Only claim a takeover when one actually happened. + // + // This used to log "took over in-flight stream(s)" unconditionally — including the case + // where `aborted` and `reconciled` are BOTH empty, which means we took over nothing: the + // live row belongs to another web instance (the abort registry is in-process), it is still + // generating, and this send is about to start a SECOND generation alongside it. + // + // That is the exact moment an operator most needs the truth, and the log was saying the + // opposite. The `unstoppable` warn above already states it; this line was quietly + // contradicting it in the same breath. A log that misreports is a signal that attests to + // nothing, which is the same defect as a test that cannot fail. + if (aborted.length > 0 || reconcile.length > 0) { + loggers.ai.info('AI Chat API: took over in-flight stream(s) on this conversation', { + conversationId, + channelId, + aborted, + reconciled: reconcile, + }); + } + + return { aborted, reconciled: reconcile }; + } catch (error) { + // Never block the send on a failed takeover — the worst case is the + // pre-existing behaviour (a concurrent generation), not a locked chat. + loggers.ai.warn('AI Chat API: stream takeover failed', { + conversationId, + error: error instanceof Error ? error.message : 'unknown', + }); + return { aborted: [], reconciled: [] }; + } +}; diff --git a/apps/web/src/lib/ai/shared/hooks/__tests__/useConversationIdentity.test.ts b/apps/web/src/lib/ai/shared/hooks/__tests__/useConversationIdentity.test.ts index 6b3d6910df..0dee78c4d6 100644 --- a/apps/web/src/lib/ai/shared/hooks/__tests__/useConversationIdentity.test.ts +++ b/apps/web/src/lib/ai/shared/hooks/__tests__/useConversationIdentity.test.ts @@ -69,4 +69,50 @@ describe('useConversationIdentity', () => { }); expect(resolve).toHaveBeenCalledTimes(2); }); + + // The reducer already drops a stale RESOLVED, but `isPersisted` lives in React state + // OUTSIDE the reducer and needed the same protection explicitly. Without it, a resolve + // still in flight when the user picks a conversation from History lands afterwards and + // flips isPersisted — and the AI-page loaders SKIP a conversation they believe has no + // server-side rows, so the user stares at an empty chat for a real conversation. + describe('a stale resolve must not clobber isPersisted', () => { + it('given the user selects a persisted conversation while a resolve is in flight, the late resolve should NOT flip isPersisted to false', async () => { + let settle!: (r: { conversationId: string; isPersisted?: boolean }) => void; + const resolve = vi.fn(() => new Promise<{ conversationId: string; isPersisted?: boolean }>((res) => { settle = res; })); + + const { result } = renderHook(() => useConversationIdentity({ resolve })); + expect(result.current.state.status).toBe('resolving'); + + // The user picks a real conversation from History before the resolve lands. + act(() => { result.current.setIdentity('conv-from-history', { isPersisted: true }); }); + expect(result.current.isPersisted).toBe(true); + + // The stale resolve now lands, claiming a brand-new (unpersisted) conversation. + await act(async () => { + settle({ conversationId: 'conv-fresh', isPersisted: false }); + await Promise.resolve(); + }); + + expect(result.current.state).toEqual({ status: 'ready', conversationId: 'conv-from-history' }); + expect(result.current.isPersisted).toBe(true); + }); + + it('given no interleaving setIdentity, the resolve should still apply its isPersisted', async () => { + const resolve = vi.fn().mockResolvedValue({ conversationId: 'conv-fresh', isPersisted: false }); + + const { result } = renderHook(() => useConversationIdentity({ resolve })); + + await waitFor(() => expect(result.current.state.status).toBe('ready')); + expect(result.current.isPersisted).toBe(false); + }); + + it('given a resolve result with no isPersisted field, should default to true', async () => { + const resolve = vi.fn().mockResolvedValue({ conversationId: 'conv-1' }); + + const { result } = renderHook(() => useConversationIdentity({ resolve })); + + await waitFor(() => expect(result.current.state.status).toBe('ready')); + expect(result.current.isPersisted).toBe(true); + }); + }); }); diff --git a/apps/web/src/lib/ai/shared/hooks/index.ts b/apps/web/src/lib/ai/shared/hooks/index.ts index 3b8cbc0714..31be616cdf 100644 --- a/apps/web/src/lib/ai/shared/hooks/index.ts +++ b/apps/web/src/lib/ai/shared/hooks/index.ts @@ -5,6 +5,7 @@ export { useMCPTools } from './useMCPTools'; export { useConversations } from './useConversations'; export { useConversationIdentity } from './useConversationIdentity'; +export type { ConversationIdentityResolveResult } from './useConversationIdentity'; export { useMessageActions } from './useMessageActions'; export { useProviderSettings } from './useProviderSettings'; export { useChatTransport } from './useChatTransport'; diff --git a/apps/web/src/lib/ai/shared/hooks/useChatTransport.ts b/apps/web/src/lib/ai/shared/hooks/useChatTransport.ts index b06451e3fa..bf251c6639 100644 --- a/apps/web/src/lib/ai/shared/hooks/useChatTransport.ts +++ b/apps/web/src/lib/ai/shared/hooks/useChatTransport.ts @@ -4,29 +4,74 @@ import { createStreamTrackingFetch } from '@/lib/ai/core/client'; /** * Creates a stable DefaultChatTransport instance that only recreates when - * the conversation ID or API endpoint changes. Avoids unnecessary useChat - * state resets caused by new transport identity. + * the conversation ID, API endpoint or channel changes. Avoids unnecessary + * useChat state resets caused by new transport identity. + * + * `channelId` is the socket room the server broadcasts this stream's lifecycle + * on — the same id the surface passes to `useChannelStreamSocket`. It is NOT + * interchangeable with `conversationId` (SidebarChatTab's transport key is + * `sidebar:`; GlobalChatContext's is the conversation id itself), so it + * has to be supplied explicitly. Every POST through this transport marks the + * channel as "being consumed by this browser context" for as long as it is + * reading the response body, which is what stops the originating tab from + * double-rendering its own stream off the socket. See `consumingChannels.ts`. * * Returns null when conversationId is null (no active conversation). */ export function useChatTransport( conversationId: string | null, - api: string + api: string, + /** + * REQUIRED, deliberately — `string | null`, never optional. + * + * This is the socket channel the server broadcasts this stream's lifecycle on, and every POST + * through this transport marks it as "being consumed by this browser context" for as long as + * the response body is being read. That mark is the ONE signal that stops the originating tab + * from also attaching to its own stream off the socket (see consumingChannels + AC1). + * + * When this was optional, a caller could simply forget it — and two did. The agent-mode + * transports omitted it, so their channel was never marked: the originator treated its own + * `chat:stream_start` as an attachable remote stream, joined its own multicast, and appended a + * synthesized assistant message on top of the one useChat already had. Every reply rendered + * twice, and nothing failed. + * + * A caller with genuinely no channel must say so explicitly by passing `null`. Forgetting is + * now a compile error, which is the only reliable way to keep it from happening again. + */ + channelId: string | null, ): DefaultChatTransport | null { const transportRef = useRef | null>(null); - const trackingIdRef = useRef(null); const apiRef = useRef(api); + // The tracking keys are held in refs and read at FETCH time, because the transport this + // fetch lives in is effectively immortal: useChat only rebuilds its `Chat` when its `id` + // changes, every caller passes a constant id, and `Chat` binds its transport once in the + // constructor. So a transport rebuilt here on a conversation switch is constructed and + // thrown away — the FIRST one keeps serving every POST for the life of the surface. + // + // Recreating the transport was therefore not just useless, it was actively misleading: it + // looked like the keys were being refreshed when nothing downstream ever picked them up. + // Build it once; let the keys follow the surface through the refs. + const chatIdRef = useRef(conversationId); + const channelIdRef = useRef(channelId); + chatIdRef.current = conversationId; + channelIdRef.current = channelId; + if (!conversationId) { return null; } - if (trackingIdRef.current !== conversationId || apiRef.current !== api || !transportRef.current) { + // `api` still forces a rebuild: a genuinely different endpoint is a different transport, and + // on the surfaces where the URL carries the conversation id the server already compensates + // for the same freeze (see /api/ai/global/[id]/messages). + if (apiRef.current !== api || !transportRef.current) { transportRef.current = new DefaultChatTransport({ api, - fetch: createStreamTrackingFetch({ chatId: conversationId }), + fetch: createStreamTrackingFetch({ + getChatId: () => chatIdRef.current, + getChannelId: () => channelIdRef.current ?? undefined, + }), }); - trackingIdRef.current = conversationId; apiRef.current = api; } diff --git a/apps/web/src/lib/ai/shared/hooks/useConversationIdentity.ts b/apps/web/src/lib/ai/shared/hooks/useConversationIdentity.ts index d87edbd0c8..730cebdcde 100644 --- a/apps/web/src/lib/ai/shared/hooks/useConversationIdentity.ts +++ b/apps/web/src/lib/ai/shared/hooks/useConversationIdentity.ts @@ -6,7 +6,7 @@ * instead of each hand-rolling the same reducer + effect boilerplate. */ -import { useCallback, useEffect, useReducer, useRef } from 'react'; +import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; import { conversationIdentityReducer, canSend as canSendState, @@ -15,6 +15,19 @@ import { export interface ConversationIdentityResolveResult { conversationId: string; + /** + * Whether this id refers to a conversation that exists server-side. + * + * The surfaces used to answer this by string-matching a sentinel id + * (`${pageId}-default`), which broke the moment the server accepted that + * sentinel and wrote a real row under it: the messages persisted, and the + * client then refused to load them because the id still "looked like" a + * placeholder. Gate on the fact, not the shape of the string. + * + * Defaults to true — a surface that only ever resolves to conversations it + * fetched from the server never needs to think about this. + */ + isPersisted?: boolean; } export interface UseConversationIdentityOptions { @@ -25,8 +38,18 @@ export interface UseConversationIdentityOptions { export interface UseConversationIdentityResult { state: ConversationIdentityState; canSend: boolean; + /** False when the current id has no server-side conversation yet (a freshly minted one). */ + isPersisted: boolean; /** Adopt a known id immediately (new conversation, or one picked from history) — synchronous, no dependency on any fetch resolving. */ - setIdentity: (conversationId: string) => void; + setIdentity: (conversationId: string, options?: { isPersisted?: boolean }) => void; + /** + * Declare whether the current id exists server-side. Set true when a send creates + * the row; set false again if the server turns out not to have it (a failed send — + * the credit gate runs BEFORE the conversation is persisted — or a conversation + * deleted elsewhere), so the surface falls back to "fresh chat" instead of showing + * a load-failure banner for a conversation that was never created. + */ + setPersisted: (isPersisted: boolean) => void; /** Re-run resolve() after an error. */ retry: () => void; } @@ -35,6 +58,7 @@ export function useConversationIdentity({ resolve, }: UseConversationIdentityOptions): UseConversationIdentityResult { const [state, dispatch] = useReducer(conversationIdentityReducer, { status: 'idle' as const }); + const [isPersisted, setIsPersisted] = useState(true); const resolveRef = useRef(resolve); resolveRef.current = resolve; @@ -47,14 +71,27 @@ export function useConversationIdentity({ }; }, []); + // The reducer already drops a stale RESOLVED ("a stale resolve must never clobber an + // identity the user has already set more recently"), but `isPersisted` lives in React + // state OUTSIDE the reducer, so it needs the same protection explicitly. Without it, a + // resolve still in flight when the user picks a conversation from History lands + // afterwards and flips isPersisted to `false` — the loaders then SKIP that real + // conversation and the user stares at an empty chat. + const resolveGenerationRef = useRef(0); + const runResolve = useCallback((startAction: { type: 'RESOLVE_STARTED' } | { type: 'RETRY' }) => { + const generation = (resolveGenerationRef.current += 1); dispatch(startAction); resolveRef.current().then( (result) => { - if (isMountedRef.current) dispatch({ type: 'RESOLVED', conversationId: result.conversationId }); + if (!isMountedRef.current) return; + if (resolveGenerationRef.current !== generation) return; // superseded + setIsPersisted(result.isPersisted ?? true); + dispatch({ type: 'RESOLVED', conversationId: result.conversationId }); }, (error) => { if (!isMountedRef.current) return; + if (resolveGenerationRef.current !== generation) return; // superseded const message = error instanceof Error ? error.message : 'Failed to resolve conversation'; dispatch({ type: 'RESOLVE_FAILED', message }); } @@ -66,13 +103,21 @@ export function useConversationIdentity({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const setIdentity = useCallback((conversationId: string) => { + const setIdentity = useCallback((conversationId: string, options?: { isPersisted?: boolean }) => { + // Invalidate any resolve still in flight — the user has chosen, and its answer is now + // stale for BOTH the id (the reducer's job) and isPersisted (ours). + resolveGenerationRef.current += 1; + setIsPersisted(options?.isPersisted ?? true); dispatch({ type: 'IDENTITY_SET', conversationId }); }, []); + const setPersisted = useCallback((next: boolean) => { + setIsPersisted(next); + }, []); + const retry = useCallback(() => { runResolve({ type: 'RETRY' }); }, [runResolve]); - return { state, canSend: canSendState(state), setIdentity, retry }; + return { state, canSend: canSendState(state), isPersisted, setIdentity, setPersisted, retry }; } diff --git a/apps/web/src/lib/ai/streams/__tests__/consumingChannels.test.ts b/apps/web/src/lib/ai/streams/__tests__/consumingChannels.test.ts new file mode 100644 index 0000000000..aaed7321d5 --- /dev/null +++ b/apps/web/src/lib/ai/streams/__tests__/consumingChannels.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + markChannelConsuming, + unmarkChannelConsuming, + isChannelConsuming, + resetConsumingChannels, +} from '../consumingChannels'; + +describe('consumingChannels', () => { + beforeEach(() => { + resetConsumingChannels(); + }); + + it('given an unmarked channel, should report not consuming', () => { + expect(isChannelConsuming('page-a')).toBe(false); + }); + + it('given a marked channel, should report consuming', () => { + markChannelConsuming('page-a'); + expect(isChannelConsuming('page-a')).toBe(true); + }); + + it('given a marked channel, should not leak into other channels', () => { + markChannelConsuming('page-a'); + expect(isChannelConsuming('page-b')).toBe(false); + }); + + it('given a channel marked then unmarked, should report not consuming', () => { + markChannelConsuming('page-a'); + unmarkChannelConsuming('page-a'); + expect(isChannelConsuming('page-a')).toBe(false); + }); + + // REFCOUNTED. GlobalAssistantView (agent mode) and SidebarChatTab (agent mode) + // co-mount on the SAME channel (selectedAgent.id) with independent useChat instances, + // and both can be streaming. With plain Set semantics the first body to finish would + // clear the flag for the other — which is still reading its own body — and the next + // reconnect bootstrap would attach it to a stream it is already rendering. + it('given two consumers on one channel and only one finishes, should STILL report consuming', () => { + markChannelConsuming('page-a'); + markChannelConsuming('page-a'); + + unmarkChannelConsuming('page-a'); + + expect(isChannelConsuming('page-a')).toBe(true); + }); + + it('given two consumers on one channel and both finish, should report not consuming', () => { + markChannelConsuming('page-a'); + markChannelConsuming('page-a'); + + unmarkChannelConsuming('page-a'); + unmarkChannelConsuming('page-a'); + + expect(isChannelConsuming('page-a')).toBe(false); + }); + + it('given more unmarks than marks, should not go negative (a stale release must not make a later mark a no-op)', () => { + markChannelConsuming('page-a'); + unmarkChannelConsuming('page-a'); + unmarkChannelConsuming('page-a'); + unmarkChannelConsuming('page-a'); + + expect(isChannelConsuming('page-a')).toBe(false); + + markChannelConsuming('page-a'); + expect(isChannelConsuming('page-a')).toBe(true); + unmarkChannelConsuming('page-a'); + expect(isChannelConsuming('page-a')).toBe(false); + }); + + it('given an unmark for a channel that was never marked, should be a no-op', () => { + expect(() => unmarkChannelConsuming('page-never')).not.toThrow(); + expect(isChannelConsuming('page-never')).toBe(false); + }); + + // THE property this module exists for. A fresh document evaluates this module from + // scratch, so the set is empty — which is exactly what makes a reloaded tab stop + // classifying itself as "the originator" and re-attach to its own live stream. + // (sessionStorage-backed browserSessionId, the thing this replaced, survives reload.) + it('given a fresh module evaluation (i.e. a page reload), should report nothing as consuming', async () => { + markChannelConsuming('page-a'); + expect(isChannelConsuming('page-a')).toBe(true); + + // A reload re-evaluates the module graph from scratch. resetModules is the closest + // faithful analogue: the next import gets a brand-new Set. + vi.resetModules(); + const reloaded = await import('../consumingChannels'); + + expect(reloaded.isChannelConsuming('page-a')).toBe(false); + }); +}); diff --git a/apps/web/src/lib/ai/streams/__tests__/isPlaceholderConversationId.test.ts b/apps/web/src/lib/ai/streams/__tests__/isPlaceholderConversationId.test.ts deleted file mode 100644 index a45cb73b19..0000000000 --- a/apps/web/src/lib/ai/streams/__tests__/isPlaceholderConversationId.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { isPlaceholderConversationId } from '../isPlaceholderConversationId'; - -describe('isPlaceholderConversationId', () => { - it('given the channel-scoped default, should be true', () => { - expect(isPlaceholderConversationId('page-123-default', 'page-123')).toBe(true); - }); - - it('given a real conversation id (not the placeholder), should be false', () => { - expect(isPlaceholderConversationId('conv-abc', 'page-123')).toBe(false); - }); - - it('given null, should be false', () => { - expect(isPlaceholderConversationId(null, 'page-123')).toBe(false); - }); - - it('given undefined, should be false', () => { - expect(isPlaceholderConversationId(undefined, 'page-123')).toBe(false); - }); - - it('given a placeholder for a different channel, should be false', () => { - expect(isPlaceholderConversationId('page-other-default', 'page-123')).toBe(false); - }); -}); diff --git a/apps/web/src/lib/ai/streams/__tests__/shouldAttachStream.test.ts b/apps/web/src/lib/ai/streams/__tests__/shouldAttachStream.test.ts new file mode 100644 index 0000000000..32d7768ccf --- /dev/null +++ b/apps/web/src/lib/ai/streams/__tests__/shouldAttachStream.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { shouldAttachStream } from '../shouldAttachStream'; + +describe('shouldAttachStream', () => { + it('given a stream this context is already consuming over the POST body, should decline (attaching twice renders every token twice)', () => { + expect(shouldAttachStream({ isOwn: true, isConsuming: true })).toBe(false); + }); + + // The reload property. `browserSessionId` lives in sessionStorage and SURVIVES a + // reload; the consuming set is module state and does not. Under the old blanket + // own-session skip, a reloaded tab still looked like the originator and dropped its + // own stream forever — no bubble, no Stop button, live input, while the server kept + // generating and editing pages. + it('given an own stream this context is NOT consuming (a reloaded tab), should attach', () => { + expect(shouldAttachStream({ isOwn: true, isConsuming: false })).toBe(true); + }); + + // A page channel carries streams from other users and other conversations. Skipping + // purely on `isConsuming` would take multiplayer down as collateral damage. + it('given a remote stream while this context consumes its own on the same channel, should still attach', () => { + expect(shouldAttachStream({ isOwn: false, isConsuming: true })).toBe(true); + }); + + it('given a remote stream and nothing being consumed, should attach', () => { + expect(shouldAttachStream({ isOwn: false, isConsuming: false })).toBe(true); + }); +}); diff --git a/apps/web/src/lib/ai/streams/__tests__/shouldClaimGlobalStopSlot.test.ts b/apps/web/src/lib/ai/streams/__tests__/shouldClaimGlobalStopSlot.test.ts new file mode 100644 index 0000000000..58d30ff63b --- /dev/null +++ b/apps/web/src/lib/ai/streams/__tests__/shouldClaimGlobalStopSlot.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { shouldClaimGlobalStopSlot } from '../shouldClaimGlobalStopSlot'; + +const claim = (o: Partial[0]> = {}) => + shouldClaimGlobalStopSlot({ + incomingMessageId: 'M1', + incomingConversationId: 'C1', + heldMessageId: null, + heldConversationId: null, + activeConversationId: 'C1', + ...o, + }); + +describe('shouldClaimGlobalStopSlot', () => { + describe('conversation scoping', () => { + it('claims a stream belonging to the conversation on screen', () => { + expect(claim()).toBe(true); + }); + + it('given a stream for a DIFFERENT resolved conversation, should refuse — that Stop button is not ours to light', () => { + expect(claim({ incomingConversationId: 'C2', activeConversationId: 'C1' })).toBe(false); + }); + + it('given identity not yet resolved, should still claim — a null active id means UNKNOWN, not "no conversation"', () => { + // The tolerant claim exists because the DB bootstrap routinely lands before the surface + // has resolved its conversation. Rejecting here would drop the very stream we are about + // to render. + expect(claim({ activeConversationId: null })).toBe(true); + }); + }); + + describe('single-writer', () => { + it('claims a free slot', () => { + expect(claim({ heldMessageId: null })).toBe(true); + }); + + it('re-claiming the stream we already hold is idempotent', () => { + expect(claim({ heldMessageId: 'M1', heldConversationId: 'C1' })).toBe(true); + }); + + it('THE BUG: a second own stream must not evict an equally-certain incumbent', () => { + // Both claims made in ignorance, in one bootstrap sweep. The unconditional claim let M2 + // overwrite M1 — M1's finalize was then dropped, and with no re-claim protocol the slot + // could be left holding a stream the user is not looking at, forever. + expect( + claim({ + incomingMessageId: 'M2', + incomingConversationId: 'C2', + heldMessageId: 'M1', + heldConversationId: 'C1', + activeConversationId: null, + }), + ).toBe(false); + }); + + it('given two live own streams and a RESOLVED identity, should let the exact match evict a claim made in ignorance', () => { + // M1 claimed while identity was unknown (so it named C1 but could not prove it was the + // one on screen). Identity then resolved to C2, and M2 is the stream actually on screen. + // M2 is strictly more certain, so it takes the slot. + expect( + claim({ + incomingMessageId: 'M2', + incomingConversationId: 'C2', + heldMessageId: 'M1', + heldConversationId: 'C1', + activeConversationId: 'C2', + }), + ).toBe(true); + }); + + it('given an incumbent that already matches the resolved conversation, should refuse to evict it', () => { + // Two live streams in ONE conversation should be impossible (takeover), but if it happens + // the incumbent is no less certain than we are — first writer wins rather than thrash. + expect( + claim({ + incomingMessageId: 'M2', + incomingConversationId: 'C1', + heldMessageId: 'M1', + heldConversationId: 'C1', + activeConversationId: 'C1', + }), + ).toBe(false); + }); + + it('given an unresolved identity and an incumbent, should never evict — we cannot prove we are the better claim', () => { + expect( + claim({ + incomingMessageId: 'M2', + incomingConversationId: 'C1', + heldMessageId: 'M1', + heldConversationId: 'C1', + activeConversationId: null, + }), + ).toBe(false); + }); + }); +}); diff --git a/apps/web/src/lib/ai/streams/consumingChannels.ts b/apps/web/src/lib/ai/streams/consumingChannels.ts new file mode 100644 index 0000000000..a9c99d8ffa --- /dev/null +++ b/apps/web/src/lib/ai/streams/consumingChannels.ts @@ -0,0 +1,55 @@ +/** + * Tracks which channels this browser context is currently consuming an AI stream body + * for, over the HTTP POST response (i.e. via `useChat`'s fetch). + * + * This is the ONLY thing that makes a live `chat:stream_start` event uninteresting to + * the tab that triggered it: if `useChat` is already reading the tokens off the POST + * body, joining the server multicast as well would render every token twice. + * + * The critical property is what happens on a reload: this is module state, so it is + * **empty in a freshly-loaded document**. A reloaded tab is therefore no longer "the + * originator" of anything and attaches to its own in-flight server stream like any + * other subscriber. (The previous mechanism keyed off `getBrowserSessionId()`, which + * reads `sessionStorage` and *survives* the reload — so a reloaded tab classified + * itself as the originator forever and dropped its own stream on the floor.) + * + * REFCOUNTED, not a plain Set. Two surfaces can be co-mounted on the same channel and + * both be streaming: GlobalAssistantView (agent mode) and SidebarChatTab (agent mode) + * share `selectedAgent.id` as their channel while holding independent `useChat` + * instances. With a Set, whichever response body finished first would clear the flag + * for the other — which is still reading its own body — and the next reconnect + * bootstrap would attach it to the multicast for a stream it is already rendering, + * double-rendering every remaining token. + * + * Marked synchronously before the POST leaves the client and released exactly once + * when the response body finishes — see `createStreamTrackingFetch`. Deliberately NOT + * derived from `getActiveStreamId({chatId})`, which is only populated once the POST + * response *headers* land and can lose the race against the server's + * `broadcastAiStreamStart`. + */ +const consumerCounts = new Map(); + +export const markChannelConsuming = (channelId: string): void => { + consumerCounts.set(channelId, (consumerCounts.get(channelId) ?? 0) + 1); +}; + +/** + * Release one consumer. The caller must guarantee exactly one release per mark — + * `createStreamTrackingFetch` does, via a once-only guard on the response body. + */ +export const unmarkChannelConsuming = (channelId: string): void => { + const next = (consumerCounts.get(channelId) ?? 0) - 1; + if (next > 0) { + consumerCounts.set(channelId, next); + return; + } + consumerCounts.delete(channelId); +}; + +export const isChannelConsuming = (channelId: string): boolean => + (consumerCounts.get(channelId) ?? 0) > 0; + +/** Test-only reset; module state otherwise leaks across cases. */ +export const resetConsumingChannels = (): void => { + consumerCounts.clear(); +}; diff --git a/apps/web/src/lib/ai/streams/isPlaceholderConversationId.ts b/apps/web/src/lib/ai/streams/isPlaceholderConversationId.ts deleted file mode 100644 index 59da155ad0..0000000000 --- a/apps/web/src/lib/ai/streams/isPlaceholderConversationId.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * True when `conversationId` is the channel-scoped placeholder a surface uses - * before a real persisted conversation id arrives — e.g. `${pageId}-default` - * for AiChatView. Used by the late-joiner reconciliation path to detect when - * the synthesized assistant message needs to wait for the real id before - * being appended. - */ -export const isPlaceholderConversationId = ( - conversationId: string | null | undefined, - channelId: string, -): boolean => conversationId === `${channelId}-default`; diff --git a/apps/web/src/lib/ai/streams/resolveActiveAssistantMessageId.ts b/apps/web/src/lib/ai/streams/resolveActiveAssistantMessageId.ts index 047d597547..144874abce 100644 --- a/apps/web/src/lib/ai/streams/resolveActiveAssistantMessageId.ts +++ b/apps/web/src/lib/ai/streams/resolveActiveAssistantMessageId.ts @@ -13,8 +13,19 @@ * 2. The last assistant message id, but only while `useChat` is actively * streaming it (the rendered streaming bubble === serverAssistantMessageId). * - * Returns `undefined` only in the brief `submitted`-before-first-chunk window - * where no assistant id exists yet; callers fall back to the chatId abort. + * CALLERS MUST PASS `isStreaming` FROM `status === 'streaming'`, never from the looser + * `submitted || streaming`. useChat sets status='submitted' BEFORE issuing the request and + * pushes the new assistant message only inside write(), which flips the status to 'streaming' + * in the same job. So during the whole submitted window the array's last assistant message is + * THE PREVIOUS TURN'S reply — and resolving to it means aborting a message that finished + * minutes ago while the real generation keeps running and keeps billing. + * + * (An earlier version of this docstring claimed the submitted window is one "where no assistant + * id exists yet". That is true only of the very first turn of a conversation. On every turn + * after that, an id exists — it is just the wrong one.) + * + * Returns `undefined` in the `submitted`-before-first-chunk window; callers fall back to the + * chatId abort, which is correct there. */ export const resolveActiveAssistantMessageId = ({ ownStreamMessageId, diff --git a/apps/web/src/lib/ai/streams/shouldAttachStream.ts b/apps/web/src/lib/ai/streams/shouldAttachStream.ts new file mode 100644 index 0000000000..1fb9c6e4b7 --- /dev/null +++ b/apps/web/src/lib/ai/streams/shouldAttachStream.ts @@ -0,0 +1,29 @@ +/** + * Decides whether a client should subscribe to a server stream it has just + * learned about (from a live `chat:stream_start`, or from the DB bootstrap). + * + * Streams are server-owned; a client is a subscriber. The one and only reason + * to decline a subscription is that this browser context is ALREADY consuming + * the very same stream over the POST response body — attaching twice would + * render every token twice. + * + * `isOwn` alone is not sufficient (that was the bug): `browserSessionId` lives + * in `sessionStorage` and survives a reload, so a reloaded tab still looks like + * the originator while consuming nothing. + * + * `isConsuming` alone is not sufficient either: it is keyed by channel, and a + * page channel carries streams from other users and other conversations. A tab + * consuming its own stream must still attach to a *remote* one on the same + * channel, or multiplayer goes dark. + * + * So: decline only when both are true. + */ +export const shouldAttachStream = ({ + isOwn, + isConsuming, +}: { + /** The stream was triggered by this browser session (`triggeredBy.browserSessionId`). */ + isOwn: boolean; + /** This browser context is currently reading a stream body for the stream's channel. */ + isConsuming: boolean; +}): boolean => !(isOwn && isConsuming); diff --git a/apps/web/src/lib/ai/streams/shouldClaimGlobalStopSlot.ts b/apps/web/src/lib/ai/streams/shouldClaimGlobalStopSlot.ts new file mode 100644 index 0000000000..34a8a6e311 --- /dev/null +++ b/apps/web/src/lib/ai/streams/shouldClaimGlobalStopSlot.ts @@ -0,0 +1,52 @@ +/** + * Single-writer guard for the Global Assistant's stop-streaming slot — the twin of + * `shouldClaimAgentStopSlot`, which had this guard while the global side claimed + * unconditionally and silently overwrote whatever was there. + * + * The bootstrap sweep calls `onOwnStreamBootstrap` once per own in-flight stream, so two live + * own streams (send in A → New Chat → send in B → reload) both arrive in a single loop — and + * often while the surface's conversation identity is still unresolved. An unconditional claim + * let the second destroy the first: the first's finalize was then dropped (the claim no longer + * named its messageId) and there is no re-claim protocol, so the conversation actually on + * screen could be left with a live stream and NO Stop button and no streaming indicator, for + * the rest of the session. + * + * Precedence: + * 1. A claim for the stream we already hold is idempotent — re-claiming is always fine. + * 2. A stream that EXACTLY matches the resolved conversation is authoritative: it may take + * the slot from a claim made in ignorance (identity still unknown when it was made). + * 3. Otherwise, first writer wins. A later stream may not evict an equally-good incumbent. + * + * `activeConversationId === null` means "this surface has not resolved its conversation yet", + * NOT "this surface has no conversation" — the distinction is the whole reason the tolerant + * claim exists, and collapsing the two is what made the original bug possible. + */ +export const shouldClaimGlobalStopSlot = ({ + incomingMessageId, + incomingConversationId, + heldMessageId, + heldConversationId, + activeConversationId, +}: { + incomingMessageId: string; + incomingConversationId: string; + /** The messageId of the stream currently holding the slot, or null if the slot is free. */ + heldMessageId: string | null; + /** The conversation the incumbent claim named, or null if it never knew. */ + heldConversationId: string | null; + /** The conversation this surface is showing, or null if not yet resolved. */ + activeConversationId: string | null; +}): boolean => { + // A known mismatch is never ours to claim: it would light the Stop button, and disable the + // composer, for a stream the user is not looking at. + if (activeConversationId !== null && incomingConversationId !== activeConversationId) { + return false; + } + // Free slot, or a re-claim of the stream we already hold. + if (heldMessageId === null || heldMessageId === incomingMessageId) return true; + + // Contested. We may only evict an incumbent that is strictly less certain than we are. + const weAreExact = activeConversationId !== null; // guaranteed by the mismatch check above + const incumbentIsExact = heldConversationId === activeConversationId; + return weAreExact && !incumbentIsExact; +}; diff --git a/apps/web/src/lib/repositories/__tests__/conversation-repository.test.ts b/apps/web/src/lib/repositories/__tests__/conversation-repository.test.ts index f5a292aa70..a65fe1c2a4 100644 --- a/apps/web/src/lib/repositories/__tests__/conversation-repository.test.ts +++ b/apps/web/src/lib/repositories/__tests__/conversation-repository.test.ts @@ -172,6 +172,38 @@ describe('conversationRepository.createConversation', () => { expect(mockDb.insert).not.toHaveBeenCalled(); }); + // THE SQUAT. The ownership guard used to be scoped to the caller's page — which is the one + // page an attacker would simply not stand on: + // + // 1. Victim has a legacy conversation C (real messages under page X, no conversations row). + // 2. Attacker, on their OWN page Y, sends with conversationId=C. Scoped to page Y the guard + // saw nothing, so the row was INSERTED with the ATTACKER as owner. + // 3. The victim's next send on page X finds a row it does not own and that is not shared: + // 403, PERMANENTLY. Locked out of their own history, and deletion now trusts the + // attacker's userId. + // + // "Who owns this conversation" is not a per-page question. The predicate must not mention the + // page at all — asserted here on the WHERE clause itself, because mocking the row lookup (as + // the tests above do) cannot see the scoping bug that let the wrong rows through. + it('asks who owns the conversation ACROSS ALL PAGES — a page-scoped guard let an attacker squat it from outside', async () => { + const chatMessagesWhere = vi.fn().mockResolvedValue([]); + mockSelectChain.from + .mockImplementationOnce(() => mockConversationsLookup([])) + .mockImplementationOnce(() => ({ where: chatMessagesWhere })); + mockDb.insert = vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ onConflictDoNothing: vi.fn().mockResolvedValue(undefined) }), + }); + + await conversationRepository.createConversation('conv-victim', 'attacker-user', 'attacker-page'); + + const predicate = chatMessagesWhere.mock.calls[0][0] as { kind: string; conds: Array<{ field?: string }> }; + const fields = predicate.conds.map((c) => c.field); + expect(fields).toContain('chatMessages.conversationId'); + expect(fields).toContain('chatMessages.isActive'); + // The page must NOT narrow it — that narrowing IS the vulnerability. + expect(fields).not.toContain('chatMessages.pageId'); + }); + it('creates the row when prior messages exist but all belong to the caller', async () => { mockSelectChain.from .mockImplementationOnce(() => mockConversationsLookup([])) diff --git a/apps/web/src/lib/repositories/conversation-repository.ts b/apps/web/src/lib/repositories/conversation-repository.ts index fdb26f1124..3a9b42b61a 100644 --- a/apps/web/src/lib/repositories/conversation-repository.ts +++ b/apps/web/src/lib/repositories/conversation-repository.ts @@ -88,7 +88,56 @@ export function generateTitle(preview: string): string { return preview.length > 50 ? preview.substring(0, 50) + '...' : preview; } +/** + * True when messages already exist under this `conversationId` — ON ANY PAGE — that belong to + * someone OTHER than `userId`. + * + * The "legacy conversation" shape: messages written before the conversations table was + * populated have no `conversations` row, so an existence check alone cannot tell you who + * owns them. Callers that authorize on the row therefore have to consult this too, or + * they fail OPEN on exactly the conversations they cannot see. + * + * DELIBERATELY NOT SCOPED BY PAGE. It was, and that made it answerable only about the page the + * caller happened to be standing on — which is the one page an attacker would simply not stand + * on. A conversation belongs to exactly one page, so "who owns this conversation" is not a + * per-page question, and asking it per-page let a caller squat a conversation from OUTSIDE it: + * + * 1. Victim has a legacy conversation C (real messages under page X, no `conversations` row). + * 2. Attacker, on their OWN page Y, POSTs {chatId: Y, conversationId: C}. Scoped to page Y, + * this check saw no messages and returned false — so the send was allowed and + * `createConversation` (which consults the same guard) INSERTED a row `id=C` owned by the + * ATTACKER. + * 3. The victim's next send on page X now finds a row it does not own and that is not shared: + * 403, permanently. They are locked out of their own conversation, and ownership-gated + * actions elsewhere (deletion) now trust `conversations.userId = attacker`. + * + * The attacker reads nothing (history loads are page-scoped), so this is integrity and + * availability rather than disclosure — but a permanent lockout of a victim's own history is + * not a defensible failure mode, and the row-squat is what makes it permanent. + * + * Asking the question across all pages costs nothing and answers it correctly: a legitimate + * caller's own legacy conversation contains only their messages and still passes. + * + * Module-level rather than a `this.`-method: `this` inside an object literal breaks the + * moment any caller destructures a method off the repository. + */ +async function hasConflictingMessageOwner( + conversationId: string, + userId: string, +): Promise { + const priorMessages = await db + .select({ userId: chatMessages.userId }) + .from(chatMessages) + .where(and( + eq(chatMessages.conversationId, conversationId), + eq(chatMessages.isActive, true), + )); + return priorMessages.some((m) => m.userId !== null && m.userId !== userId); +} + export const conversationRepository = { + hasConflictingMessageOwner, + /** * Eagerly create a conversations row when a conversation session is started * or continued. This establishes ownership (userId) and sets isShared=false @@ -102,6 +151,7 @@ export const conversationRepository = { * conversations.userId. Centralized here (rather than at each call site) * so every caller — including pre-existing ones — gets this guarantee. */ + async createConversation(conversationId: string, userId: string, pageId: string): Promise { const [existing] = await db .select({ id: conversations.id }) @@ -110,15 +160,7 @@ export const conversationRepository = { .limit(1); if (existing) return; - const priorMessages = await db - .select({ userId: chatMessages.userId }) - .from(chatMessages) - .where(and( - eq(chatMessages.pageId, pageId), - eq(chatMessages.conversationId, conversationId), - eq(chatMessages.isActive, true), - )); - const hasConflictingOwner = priorMessages.some((m) => m.userId !== null && m.userId !== userId); + const hasConflictingOwner = await hasConflictingMessageOwner(conversationId, userId); if (hasConflictingOwner) return; await db diff --git a/apps/web/src/lib/websocket/socket-utils.ts b/apps/web/src/lib/websocket/socket-utils.ts index 90c7270e3f..5d1e6dd6a3 100644 --- a/apps/web/src/lib/websocket/socket-utils.ts +++ b/apps/web/src/lib/websocket/socket-utils.ts @@ -702,6 +702,21 @@ export interface AiStreamStartPayload { * event without the field, and consumers degrade to a timestamp-less bubble. */ startedAt?: string; + /** + * Whether the stream's conversation is explicitly shared. + * + * A page room contains every member of the page, but conversations are PRIVATE by + * default (`listConversations` shows you only `userId = you OR isShared`). Without + * this flag every member's client would try to join every stream on the page and be + * refused — a wasted request and an `authz.access.denied` audit row per member per + * assistant message, on entirely routine private chat. + * + * Optional for the same cross-version reason as `startedAt`: during a rolling deploy + * an originator on the previous build emits no field, so consumers must treat + * `undefined` as "unknown, ask the server" and only skip on an explicit `false`. + * The server remains the authority either way (see stream-subscription-authz.ts). + */ + isShared?: boolean; triggeredBy: { userId: string; displayName: string; browserSessionId: string }; } diff --git a/packages/db/drizzle/0203_adorable_amphibian.sql b/packages/db/drizzle/0203_adorable_amphibian.sql new file mode 100644 index 0000000000..f1f9cd6230 --- /dev/null +++ b/packages/db/drizzle/0203_adorable_amphibian.sql @@ -0,0 +1,2 @@ +ALTER TABLE "ai_stream_sessions" ADD COLUMN "last_heartbeat_at" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "ai_stream_sessions_conversation_status_idx" ON "ai_stream_sessions" USING btree ("conversation_id","status"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0203_snapshot.json b/packages/db/drizzle/meta/0203_snapshot.json new file mode 100644 index 0000000000..93924fa4b8 --- /dev/null +++ b/packages/db/drizzle/meta/0203_snapshot.json @@ -0,0 +1,19681 @@ +{ + "id": "a0f6682f-f03a-4e3f-949b-7e1b4c58d308", + "prevId": "1d049488-0bf7-4360-8e88-457dd580078b", + "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'" + }, + "imageGenerationModel": { + "name": "imageGenerationModel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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()" + }, + "last_heartbeat_at": { + "name": "last_heartbeat_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": {} + }, + "ai_stream_sessions_conversation_status_idx": { + "name": "ai_stream_sessions_conversation_status_idx", + "columns": [ + { + "expression": "conversation_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.machine_sessions": { + "name": "machine_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 + }, + "egressPolicyToken": { + "name": "egressPolicyToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastActiveAt": { + "name": "lastActiveAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "storageLastBilledAt": { + "name": "storageLastBilledAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "storageMeasuredBytes": { + "name": "storageMeasuredBytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "storageMeasuredAt": { + "name": "storageMeasuredAt", + "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": { + "machine_sessions_page_id_idx": { + "name": "machine_sessions_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_sessions_last_active_at_idx": { + "name": "machine_sessions_last_active_at_idx", + "columns": [ + { + "expression": "lastActiveAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "machine_sessions_pageId_pages_id_fk": { + "name": "machine_sessions_pageId_pages_id_fk", + "tableFrom": "machine_sessions", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "machine_sessions_userId_users_id_fk": { + "name": "machine_sessions_userId_users_id_fk", + "tableFrom": "machine_sessions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "machine_sessions_sessionKey_unique": { + "name": "machine_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 + }, + "publish_landing_page_id": { + "name": "publish_landing_page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publish_not_found_page_id": { + "name": "publish_not_found_page_id", + "type": "text", + "primaryKey": false, + "notNull": 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" + }, + "custom_domains_publish_landing_page_id_pages_id_fk": { + "name": "custom_domains_publish_landing_page_id_pages_id_fk", + "tableFrom": "custom_domains", + "tableTo": "pages", + "columnsFrom": [ + "publish_landing_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "custom_domains_publish_not_found_page_id_pages_id_fk": { + "name": "custom_domains_publish_not_found_page_id_pages_id_fk", + "tableFrom": "custom_domains", + "tableTo": "pages", + "columnsFrom": [ + "publish_not_found_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 bc517a90ec..9b5d351a51 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -1422,6 +1422,13 @@ "when": 1783811607171, "tag": "0202_aspiring_warpath", "breakpoints": true + }, + { + "idx": 203, + "version": "7", + "when": 1783813639079, + "tag": "0203_adorable_amphibian", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/ai-streams.ts b/packages/db/src/schema/ai-streams.ts index 33770ed1fa..dc7d22f407 100644 --- a/packages/db/src/schema/ai-streams.ts +++ b/packages/db/src/schema/ai-streams.ts @@ -13,7 +13,36 @@ export const aiStreamSessions = pgTable('ai_stream_sessions', { // the originator's process dies, without depending on the live multicast. parts: jsonb('parts').$type().notNull().default([]), startedAt: timestamp('started_at', { mode: 'date' }).defaultNow().notNull(), + // Written on a dedicated interval by the generation (and also refreshed by each parts + // checkpoint). It CANNOT ride the checkpoint alone: a stream inside a long tool call + // pushes no parts for minutes, and a checkpoint-driven heartbeat would declare it dead. + // `status` alone cannot establish liveness either: the terminal write is fire-and-forget + // and dies with the process, so a crashed generation leaves a row that claims + // 'streaming' forever. A row whose heartbeat is stale is dead — it must never block a + // new send, and must never be served to a client as an active stream. + // + // ROLLOUT NOTE — this column makes liveness heartbeat-authoritative, and OLD workers do not + // beat. During a deploy, a stream started by a pre-heartbeat worker gets `now()` from the + // DEFAULT once and never refreshes it, so ~2 minutes later this code calls it dead: it drops + // out of /active-streams, and a takeover drives its row terminal while it is still generating. + // + // Deliberately not gated behind a two-phase flag, because the blast radius is smaller than the + // flag would be: + // - No content is lost. The assistant message is persisted through the normal + // message-persistence path, not through this table — `parts` here is only a mid-stream + // crash-recovery snapshot, and the old worker's own terminal write corrects `status` when + // it finishes. + // - The worst case is a concurrent generation on that conversation, which is EXACTLY what + // master does today (master has no takeover at all). So an old-worker conversation simply + // behaves as it does now until its worker drains. + // - The window is one rolling-deploy drain, and streams are minutes long. + // + // It is still a window. Deploy with a rolling strategy that lets old machines finish their + // in-flight streams (Fly does this by default); do not hard-cut all workers at once. + lastHeartbeatAt: timestamp('last_heartbeat_at', { mode: 'date' }).defaultNow().notNull(), completedAt: timestamp('completed_at', { mode: 'date' }), }, (table) => ({ channelStatusIdx: index('ai_stream_sessions_channel_status_idx').on(table.channelId, table.status), + // Per-conversation in-flight lookup for the takeover guard in POST /api/ai/chat. + conversationStatusIdx: index('ai_stream_sessions_conversation_status_idx').on(table.conversationId, table.status), }));