From 7ea77760cd24f45fb266169053cdb130d2767769 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 12 Jul 2026 00:39:48 -0500 Subject: [PATCH 1/9] =?UTF-8?q?feat(ai):=20server-owned=20AI=20streams=20?= =?UTF-8?q?=E2=80=94=20clients=20become=20pure=20subscribers=20(core)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stream is now a SERVER-SIDE entity with its own identity, lifecycle and durable state. A client is a subscriber. Any client that opens the conversation — the originator, a fresh tab, another device — attaches to whatever is live. This is the core of the work, split out from the Stop-button/ownership machine so it can be reviewed on its own merits. See the PR body. WHAT THIS FIXES An AI stream used to be implicitly owned by the browser tab that started it: the tab consuming the POST body dropped its own stream on the socket. But the server generation is deliberately disconnect-immune. So when the originating tab reloaded — which on iPad happens constantly, because iOS jetsams the WKWebView content process and Capacitor calls webView.reload() — the stream was orphaned. The server kept generating and editing pages while the reloaded tab showed no stream, no Stop button, and a live input. A second send started a SECOND generation on the same conversation. Two agents, two bills. AC1 A client can attach to its OWN live stream. The blanket own-session skip becomes `!(isOwn && isConsuming)`, where isConsuming is a synchronous refcount marked before the POST leaves. It is module state, so it is EMPTY after a reload — and that emptiness IS the fix. AC2 Attach works on reload, on reconnect and on cold open, with no gap and no double-render. A failed cross-instance SSE join no longer suppresses the completion event, so the finished reply is loaded from the DB instead of vanishing. AC3 The `${pageId}-default` sentinel is gone. Gating on the isPersisted FACT (not the id string) recovers every existing user's stranded history with NO migration; new chats mint real cuids. The sentinel was also a real IDOR — a guessable id against a history load with no user filter. AC4 The resume path is alive when needed (gated on user editing, evaluated at fire time, because iOS freezes JS while backgrounded and a render-time capture was always stale). AC5 Per-conversation concurrency is TAKEOVER, never 409 — a 409 would self-lock the conversation, since a crashed generation's terminal write dies with its process and leaves a permanently-'streaming' row. Adds lastHeartbeatAt: a row whose heartbeat is stale is dead, must never block a send, and must never be served as active. AC6 A stream is never rendered into another conversation. AC7 Disconnect-immunity is PRESERVED — and now enforced. request.signal must never reach streamText: an aborted request means the client stopped LISTENING, not that the user pressed Stop. It was a one-line change to break, it looks like an obvious cleanup, and nothing failed if you did. It now has a tripwire. ALSO CLOSED A privacy leak: stream subscription was authorized on page access alone, which would have handed a co-member's PRIVATE conversation, token by token, to anyone who could open the page. Subscription now mirrors listConversations (`userId = me OR isShared`), fails closed, and 404s rather than 403s. A permanent lockout: legacy-conversation ownership was checked per-page — the one page an attacker would not stand on — letting them squat a victim's conversation from outside it and 403 the victim out of their own history forever. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ie3Bmn3sgiUJ7a6ErwEc4 --- .../__tests__/stream-socket-events.test.ts | 148 +- .../active-streams/__tests__/route.test.ts | 104 + .../app/api/ai/chat/active-streams/route.ts | 31 +- apps/web/src/app/api/ai/chat/route.ts | 104 +- .../[messageId]/__tests__/route.test.ts | 67 + .../ai/chat/stream-join/[messageId]/route.ts | 44 +- .../messages/__tests__/credit-gate.test.ts | 12 + .../__tests__/stream-socket-events.test.ts | 32 + .../app/api/ai/global/[id]/messages/route.ts | 12 + .../page-views/ai-page/AiChatView.tsx | 224 +- .../ai-page/__tests__/AiChatView.test.tsx | 356 +- apps/web/src/contexts/GlobalChatContext.tsx | 135 +- .../__tests__/GlobalChatContext.test.tsx | 253 +- .../useAgentChannelMultiplayer.test.ts | 14 +- .../__tests__/useAppStateRecovery.test.ts | 110 + .../__tests__/useChannelStreamSocket.test.ts | 307 +- apps/web/src/hooks/useAppStateRecovery.ts | 25 +- apps/web/src/hooks/useChannelStreamSocket.ts | 207 +- .../__tests__/disconnect-immunity.test.ts | 80 + .../__tests__/stream-abort-client.test.ts | 171 +- .../core/__tests__/stream-lifecycle.test.ts | 143 +- .../ai/core/__tests__/stream-liveness.test.ts | 131 + .../stream-multicast-registry.test.ts | 17 +- .../stream-subscription-authz.test.ts | 136 + .../ai/core/__tests__/stream-takeover.test.ts | 170 + .../src/lib/ai/core/stream-abort-client.ts | 114 +- .../src/lib/ai/core/stream-abort-registry.ts | 8 +- apps/web/src/lib/ai/core/stream-horizons.ts | 22 + apps/web/src/lib/ai/core/stream-lifecycle.ts | 100 +- apps/web/src/lib/ai/core/stream-liveness.ts | 112 + .../lib/ai/core/stream-multicast-registry.ts | 7 +- .../lib/ai/core/stream-subscription-authz.ts | 69 + apps/web/src/lib/ai/core/stream-takeover.ts | 133 + .../__tests__/useConversationIdentity.test.ts | 46 + apps/web/src/lib/ai/shared/hooks/index.ts | 1 + .../lib/ai/shared/hooks/useChatTransport.ts | 42 +- .../shared/hooks/useConversationIdentity.ts | 55 +- .../__tests__/consumingChannels.test.ts | 92 + .../isPlaceholderConversationId.test.ts | 24 - .../__tests__/shouldAttachStream.test.ts | 27 + .../shouldClaimGlobalStopSlot.test.ts | 97 + .../src/lib/ai/streams/consumingChannels.ts | 55 + .../ai/streams/isPlaceholderConversationId.ts | 11 - .../resolveActiveAssistantMessageId.ts | 15 +- .../src/lib/ai/streams/shouldAttachStream.ts | 29 + .../ai/streams/shouldClaimGlobalStopSlot.ts | 52 + .../__tests__/conversation-repository.test.ts | 32 + .../repositories/conversation-repository.ts | 60 +- apps/web/src/lib/websocket/socket-utils.ts | 15 + .../db/drizzle/0203_adorable_amphibian.sql | 2 + packages/db/drizzle/meta/0203_snapshot.json | 19681 ++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/ai-streams.ts | 10 + 53 files changed, 23733 insertions(+), 218 deletions(-) create mode 100644 apps/web/src/hooks/__tests__/useAppStateRecovery.test.ts create mode 100644 apps/web/src/lib/ai/core/__tests__/disconnect-immunity.test.ts create mode 100644 apps/web/src/lib/ai/core/__tests__/stream-liveness.test.ts create mode 100644 apps/web/src/lib/ai/core/__tests__/stream-subscription-authz.test.ts create mode 100644 apps/web/src/lib/ai/core/__tests__/stream-takeover.test.ts create mode 100644 apps/web/src/lib/ai/core/stream-horizons.ts create mode 100644 apps/web/src/lib/ai/core/stream-liveness.ts create mode 100644 apps/web/src/lib/ai/core/stream-subscription-authz.ts create mode 100644 apps/web/src/lib/ai/core/stream-takeover.ts create mode 100644 apps/web/src/lib/ai/streams/__tests__/consumingChannels.test.ts delete mode 100644 apps/web/src/lib/ai/streams/__tests__/isPlaceholderConversationId.test.ts create mode 100644 apps/web/src/lib/ai/streams/__tests__/shouldAttachStream.test.ts create mode 100644 apps/web/src/lib/ai/streams/__tests__/shouldClaimGlobalStopSlot.test.ts create mode 100644 apps/web/src/lib/ai/streams/consumingChannels.ts delete mode 100644 apps/web/src/lib/ai/streams/isPlaceholderConversationId.ts create mode 100644 apps/web/src/lib/ai/streams/shouldAttachStream.ts create mode 100644 apps/web/src/lib/ai/streams/shouldClaimGlobalStopSlot.ts create mode 100644 packages/db/drizzle/0203_adorable_amphibian.sql create mode 100644 packages/db/drizzle/meta/0203_snapshot.json 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..8ae0c9b3a8 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,7 @@ const { mockBroadcastChatUserMessage, mockSaveMessageToDatabase, mockGetConversation, + mockHasConflictingMessageOwner, mockCreateConversation, } = vi.hoisted(() => ({ mockCreateStreamLifecycle: vi.fn(), @@ -24,6 +25,7 @@ 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), mockCreateConversation: vi.fn().mockResolvedValue(undefined), })); @@ -195,7 +197,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 +210,7 @@ vi.mock('@/lib/logging/mask', () => ({ vi.mock('@/lib/repositories/conversation-repository', () => ({ conversationRepository: { getConversation: mockGetConversation, + hasConflictingMessageOwner: mockHasConflictingMessageOwner, createConversation: mockCreateConversation, }, })); @@ -308,6 +312,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 +329,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', }), @@ -374,6 +381,112 @@ 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', 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); + expect(mockCreateStreamLifecycle).toHaveBeenCalled(); + }); + + 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(); + }); + }); + 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 +495,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 +538,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 +588,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 +702,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..89061260e9 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,19 @@ 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 stream is a server-owned entity: at most + // one may be generating on a conversation at a time. 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. + await takeOverConversationStreams({ + conversationId: conversationId!, + channelId: chatId, + }); + lifecycle = await createStreamLifecycle({ messageId: serverAssistantMessageId, channelId: chatId, @@ -1130,6 +1211,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..88be3f10a0 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); 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..bf95e4bf40 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,23 @@ 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); + expect(mockTakeOverConversationStreams).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: expect.any(String), channelId: expect.any(String) }), + ); + 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 +450,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..da55d95127 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,16 @@ MENTION PROCESSING: }).catch(() => {}); } + // Same per-conversation in-flight guard as POST /api/ai/chat. A stream is a + // server-owned entity: at most one may be generating on a conversation at a time. + // Without this 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. + await takeOverConversationStreams({ + conversationId, + channelId, + }); + lifecycle = await createStreamLifecycle({ messageId: serverAssistantMessageId, channelId, @@ -1093,6 +1104,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..42374b88cd 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,7 +672,16 @@ 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) { @@ -604,7 +693,7 @@ const AiChatView: React.FC = ({ page }) => { // 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]); + }, [chatStop, ownStreamMessageId, status, lastAssistantMessageId, streamTrackingId, page.id]); usePageSocketRoom(page.id); const { rejoinActiveStreams } = useChannelStreamSocket(page.id, { @@ -643,14 +732,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 +781,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 +797,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 +924,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 +954,8 @@ const AiChatView: React.FC = ({ page }) => { clearFiles(); inputRef.current?.clear(); + adoptConversationAsPersisted(); + wrapSend(async () => { const pageContext = await contextPromise; sendMessage( @@ -856,6 +993,7 @@ const AiChatView: React.FC = ({ page }) => { imageGenEnabled, mcpToolSchemas, wrapSend, + adoptConversationAsPersisted, ]); // Voice mode: Send message from voice transcript @@ -868,6 +1006,7 @@ const AiChatView: React.FC = ({ page }) => { if (!canSendMessage) return; const contextPromise = buildFreshPageContext(); + adoptConversationAsPersisted(); wrapSend(async () => { const pageContext = await contextPromise; sendMessage( @@ -900,6 +1039,7 @@ const AiChatView: React.FC = ({ page }) => { imageGenEnabled, mcpToolSchemas, wrapSend, + adoptConversationAsPersisted, ]); // Voice mode toggle handler @@ -922,6 +1062,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 +1087,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 +1111,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/contexts/GlobalChatContext.tsx b/apps/web/src/contexts/GlobalChatContext.tsx index 44a72a62ee..8bf5bbcd42 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>>; } // ============================================ @@ -267,9 +277,48 @@ export function GlobalChatProvider({ children }: { children: ReactNode }) { 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 claimedStopMessageIdRef = useRef(null); + const { rejoinActiveStreams: rejoinGlobalStream } = useChannelStreamSocket(channelId, { // Cross-tab same-user events: signal surfaces to re-fetch rather than // updating context state that nobody renders from. @@ -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; 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..08c67f4e4e 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,7 @@ describe('useChannelStreamSocket', () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(onOwnStreamBootstrap).toHaveBeenCalledTimes(1); - expect(onOwnStreamBootstrap).toHaveBeenCalledWith({ messageId: 'msg-own-bootstrap' }); + expect(onOwnStreamBootstrap).toHaveBeenCalledWith({ messageId: 'msg-own-bootstrap', conversationId: expect.any(String) }); }); 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__/disconnect-immunity.test.ts b/apps/web/src/lib/ai/core/__tests__/disconnect-immunity.test.ts new file mode 100644 index 0000000000..f15ffdcb14 --- /dev/null +++ b/apps/web/src/lib/ai/core/__tests__/disconnect-immunity.test.ts @@ -0,0 +1,80 @@ +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', +]; + +// `request.signal` / `req.signal`, in any member-access shape. +const REQUEST_SIGNAL = /\b(request|req)\s*\.\s*signal\b/; + +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 }) => REQUEST_SIGNAL.test(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(REQUEST_SIGNAL.test(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..456dd24cae --- /dev/null +++ b/apps/web/src/lib/ai/core/__tests__/stream-takeover.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const { mockSelectWhere, mockUpdateWhere, mockUpdateSet, mockAbortStreamByMessageId } = vi.hoisted(() => ({ + 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: vi.fn(), warn: vi.fn(), 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: [] }); + }); +}); 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..ff7c9dbd4e 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(); @@ -112,29 +116,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..c17dfe94c5 --- /dev/null +++ b/apps/web/src/lib/ai/core/stream-takeover.ts @@ -0,0 +1,133 @@ +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. + * + * 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) { + // 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'), + )); + } + + 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, + }); + } + + 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..3423098b6b 100644 --- a/apps/web/src/lib/ai/shared/hooks/useChatTransport.ts +++ b/apps/web/src/lib/ai/shared/hooks/useChatTransport.ts @@ -4,29 +4,57 @@ 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, + channelId?: string, ): 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, + }), }); - 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..6f4bf6d524 100644 --- a/packages/db/src/schema/ai-streams.ts +++ b/packages/db/src/schema/ai-streams.ts @@ -13,7 +13,17 @@ 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. + 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), })); From 526241448b09e77fb1f64fa213c7c08a61dcac40 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 12 Jul 2026 00:51:57 -0500 Subject: [PATCH 2/9] test(ai): close six tests that could not fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A test-suite audit — hunting the class where a fixture models an impossible state and gives false comfort — found six live instances. Two of them guarded this PR's own load-bearing claims and would have passed while the claim was violated. isShared REACHED NO ASSERTION ANYWHERE (the worst one) useChannelStreamSocket DROPS a co-member's stream when payload.isShared === false, so that flag is the SOLE signal making another member's shared conversation visible. Every assertion on it pinned only `false`; the one shared-conversation route test asserted merely that the lifecycle "was called" — not with what. Hardcode `isShared: false` in the route or the lifecycle and 100% of the suite stayed green while EVERY SHARED CONVERSATION WENT DARK FOR EVERY CO-MEMBER. Now asserted end to end. THE TAKEOVER WAS UNTESTED ON THE MAIN CHAT ROUTE (AC5's whole guarantee) stream-takeover was not even mocked in the chat-route suite, 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 nothing would have failed. Now pinned: called with the right conversation, called BEFORE the new lifecycle (the other order aborts the stream it just started), and never a 409 — a rejection would self-lock the conversation, because a crashed generation's terminal write dies with its process and leaves a permanently-'streaming' row. A MOCK LEAK ACROSS A WHOLE ROUTE FILE vi.clearAllMocks() clears calls, not implementations, and seven tests used mockResolvedValue rather than ...Once. The lifecycle-invocation test — which pins the full argument shape for "a new AI stream" — was silently running against an already-owned conversation. It passed either way, which is exactly why nobody noticed. HALF THE REVOCATION BACKSTOP WAS NEVER EXERCISED hasViewAccess() is `pageOk && canSubscribe()`, and every recheck test only varied page access. `return canSubscribe();` could be deleted and the 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. TWO ASSERTIONS TOO WEAK TO FAIL expect.any(String) on the bootstrap conversationId (which decides WHICH surface's Stop button lights up) and on the takeover's conversationId/channelId — both known constants, so a route that SWAPPED them passed. Now exact. Every one verified by reintroducing the bug: deleting the takeover fails 2, hardcoding isShared fails 1, deleting the conversation half of hasViewAccess fails 1, swapping the takeover args fails 1. Before this commit, all of them were silent. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ie3Bmn3sgiUJ7a6ErwEc4 --- .../__tests__/stream-socket-events.test.ts | 63 ++++++++++++++++++- .../[messageId]/__tests__/route.test.ts | 28 +++++++++ .../__tests__/stream-socket-events.test.ts | 8 ++- .../__tests__/useChannelStreamSocket.test.ts | 8 ++- 4 files changed, 103 insertions(+), 4 deletions(-) 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 8ae0c9b3a8..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 @@ -17,6 +17,7 @@ const { mockSaveMessageToDatabase, mockGetConversation, mockHasConflictingMessageOwner, + mockTakeOverConversationStreams, mockCreateConversation, } = vi.hoisted(() => ({ mockCreateStreamLifecycle: vi.fn(), @@ -26,6 +27,7 @@ const { 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), })); @@ -44,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, })); @@ -347,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, @@ -436,7 +450,7 @@ describe('POST /api/ai/chat — lifecycle handoff', () => { expect(mockCreateStreamLifecycle).not.toHaveBeenCalled(); }); - it('given an existing conversation owned by another user but explicitly SHARED, should allow it', async () => { + 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', }); @@ -444,7 +458,14 @@ describe('POST /api/ai/chat — lifecycle handoff', () => { const response = await POST(makeRequest({ conversationId: CONV_ID })); expect(response.status).not.toBe(403); - expect(mockCreateStreamLifecycle).toHaveBeenCalled(); + // 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 () => { @@ -487,6 +508,44 @@ describe('POST /api/ai/chat — lifecycle handoff', () => { }); }); + // 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' })); 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 88be3f10a0..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 @@ -413,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/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 bf95e4bf40..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 @@ -430,8 +430,14 @@ describe('POST /api/ai/global/[id]/messages — lifecycle handoff', () => { 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: expect.any(String), channelId: expect.any(String) }), + expect.objectContaining({ + conversationId: 'conv-1', + channelId: 'user:user-1:global', + }), ); expect(mockTakeOverConversationStreams.mock.invocationCallOrder[0]) .toBeLessThan(mockCreateStreamLifecycle.mock.invocationCallOrder[0]); diff --git a/apps/web/src/hooks/__tests__/useChannelStreamSocket.test.ts b/apps/web/src/hooks/__tests__/useChannelStreamSocket.test.ts index 08c67f4e4e..a5f06b136d 100644 --- a/apps/web/src/hooks/__tests__/useChannelStreamSocket.test.ts +++ b/apps/web/src/hooks/__tests__/useChannelStreamSocket.test.ts @@ -1526,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', conversationId: expect.any(String) }); + // 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 () => { From 4bc8c2049589197a18f841989d8fadb58d64487f Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 12 Jul 2026 01:06:14 -0500 Subject: [PATCH 3/9] fix(ai): channelId is required; takeover reports the truth on partial failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reviewer findings on the core PR. The first is a bug my own split introduced. 1. THE SPLIT DROPPED channelId ON BOTH AGENT TRANSPORTS (Codex, P1 — correct) Splitting the ownership machine out returned GlobalAssistantView and SidebarChatTab to their master state, where they call useChatTransport WITHOUT a channelId. So the agent channel was never marked as being consumed — and consumingChannels is the ONE signal that stops the originating tab from also attaching to its own stream off the socket. 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 agent reply would have rendered twice, and nothing would have failed. Taking the reviewer's stronger option: channelId is now REQUIRED (`string | null`, never optional). A caller with no channel must say so explicitly; forgetting is a compile error. The type change immediately surfaced a third caller that was passing `string | undefined` — which is precisely the point. 2. THE TAKEOVER LIED ON PARTIAL FAILURE (CodeRabbit — correct) One try/catch wrapped the whole function. The aborts land FIRST — real in-process generations are STOPPED — and only then does the reconcile UPDATE run. If that UPDATE threw, the catch returned `{aborted: [], reconciled: []}`: "nothing happened", when streams had in fact been stopped and their rows still read 'streaming'. Every reader then treats a dead generation as live, and the caller is told the exact opposite of the truth. The UPDATE now has its own catch and returns what was ACTUALLY aborted. The rows self-heal: a stopped generation stops beating, so within STREAM_HEARTBEAT_STALE_MS the liveness predicate calls them dead and the next takeover reconciles them — which is exactly the failure mode the heartbeat exists to absorb. 3. HEARTBEAT-AUTHORITATIVE LIVENESS HAS A ROLLOUT WINDOW (CodeRabbit — real, documented, not gated) Old workers do not beat, so during a deploy their still-running streams age out and get driven terminal. Documented in the schema rather than hidden behind a two-phase flag, because the blast radius is smaller than the flag: no content is lost (the message persists through the normal path; `parts` is only a crash-recovery snapshot, and the old worker's own terminal write corrects status), and the worst case — a concurrent generation — is EXACTLY what master does today, since master has no takeover at all. The window is one rolling-deploy drain. Both fixes pinned by tests verified to fail without them: restoring the single catch fails the partial-failure test; dropping channelId is now a compile error. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ie3Bmn3sgiUJ7a6ErwEc4 --- .../dashboard/GlobalAssistantView.tsx | 2 +- .../ai-assistant/SidebarChatTab.tsx | 2 +- apps/web/src/contexts/GlobalChatContext.tsx | 4 +- .../ai/core/__tests__/stream-takeover.test.ts | 49 +++++++++++++++++++ apps/web/src/lib/ai/core/stream-takeover.ts | 46 +++++++++++++---- .../lib/ai/shared/hooks/useChatTransport.ts | 23 +++++++-- packages/db/src/schema/ai-streams.ts | 19 +++++++ 7 files changed, 129 insertions(+), 16 deletions(-) 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 8bf5bbcd42..bac3c00769 100644 --- a/apps/web/src/contexts/GlobalChatContext.tsx +++ b/apps/web/src/contexts/GlobalChatContext.tsx @@ -271,7 +271,7 @@ 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; @@ -319,7 +319,7 @@ export function GlobalChatProvider({ children }: { children: ReactNode }) { const claimedStopMessageIdRef = useRef(null); - const { rejoinActiveStreams: rejoinGlobalStream } = useChannelStreamSocket(channelId, { + 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) => { 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 index 456dd24cae..253bc883c2 100644 --- a/apps/web/src/lib/ai/core/__tests__/stream-takeover.test.ts +++ b/apps/web/src/lib/ai/core/__tests__/stream-takeover.test.ts @@ -167,4 +167,53 @@ describe('takeOverConversationStreams', () => { 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: [] }); + }); + }); + }); diff --git a/apps/web/src/lib/ai/core/stream-takeover.ts b/apps/web/src/lib/ai/core/stream-takeover.ts index c17dfe94c5..00ebd93377 100644 --- a/apps/web/src/lib/ai/core/stream-takeover.ts +++ b/apps/web/src/lib/ai/core/stream-takeover.ts @@ -88,15 +88,43 @@ export const takeOverConversationStreams = async ({ const { reconcile } = decideStreamTakeover({ rows, abortedMessageIds: aborted, now }); if (reconcile.length > 0) { - // 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'), - )); + // 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 diff --git a/apps/web/src/lib/ai/shared/hooks/useChatTransport.ts b/apps/web/src/lib/ai/shared/hooks/useChatTransport.ts index 3423098b6b..bf251c6639 100644 --- a/apps/web/src/lib/ai/shared/hooks/useChatTransport.ts +++ b/apps/web/src/lib/ai/shared/hooks/useChatTransport.ts @@ -21,7 +21,24 @@ import { createStreamTrackingFetch } from '@/lib/ai/core/client'; export function useChatTransport( conversationId: string | null, api: string, - channelId?: 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 apiRef = useRef(api); @@ -36,7 +53,7 @@ export function useChatTransport( // 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); + const channelIdRef = useRef(channelId); chatIdRef.current = conversationId; channelIdRef.current = channelId; @@ -52,7 +69,7 @@ export function useChatTransport( api, fetch: createStreamTrackingFetch({ getChatId: () => chatIdRef.current, - getChannelId: () => channelIdRef.current, + getChannelId: () => channelIdRef.current ?? undefined, }), }); apiRef.current = api; diff --git a/packages/db/src/schema/ai-streams.ts b/packages/db/src/schema/ai-streams.ts index 6f4bf6d524..dc7d22f407 100644 --- a/packages/db/src/schema/ai-streams.ts +++ b/packages/db/src/schema/ai-streams.ts @@ -20,6 +20,25 @@ export const aiStreamSessions = pgTable('ai_stream_sessions', { // 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) => ({ From d56a4d6afa0cb76b5c4871308cc5823b16471b8c Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 12 Jul 2026 01:17:53 -0500 Subject: [PATCH 4/9] test(ai): the AC7 tripwire did not catch the obvious way to break AC7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I wrote a source-level tripwire for disconnect-immunity, verified it against `AbortSignal.any([abortSignal, request.signal])`, and called it done. Then I tried to defeat my own guard, and did — on the first try: const { signal } = request; A destructure. The single most natural way a developer would actually reach for the request's abort signal. It sailed straight past a member-access-only regex: the test stayed GREEN while disconnect-immunity — the load-bearing invariant of the entire architecture — was destroyed. That is exactly the false comfort this file was written to prevent, reproduced inside the file itself. A tripwire that only catches the spelling you happened to think of is not a tripwire; it is a green light that measures nothing, which is worse than no light at all. Now matches both the member access and the destructure, and verified against all four realistic forms (`request.signal`, `req.signal`, `const { signal } = request`, `const { signal: alias } = req`, and the AbortSignal.any composition) — each reintroduced independently, each caught. Stated plainly in the file that this remains a HEURISTIC: it cannot catch every aliasing (`const r = request; r.signal`), and it is not pretending to be a type system. It catches the ways the mistake actually gets made, and fails loudly with an explanation. That is worth more than a proof nobody writes. Tests only. No behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ie3Bmn3sgiUJ7a6ErwEc4 --- .../__tests__/disconnect-immunity.test.ts | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) 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 index f15ffdcb14..92312ee081 100644 --- a/apps/web/src/lib/ai/core/__tests__/disconnect-immunity.test.ts +++ b/apps/web/src/lib/ai/core/__tests__/disconnect-immunity.test.ts @@ -39,8 +39,26 @@ const GENERATION_ROUTES = [ 'app/api/ai/global/[id]/messages/route.ts', ]; -// `request.signal` / `req.signal`, in any member-access shape. -const REQUEST_SIGNAL = /\b(request|req)\s*\.\s*signal\b/; +// 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) => { @@ -50,7 +68,7 @@ describe('disconnect-immunity (AC7)', () => { const offendingLines = source .split('\n') .map((line, i) => ({ line: line.trim(), lineNo: i + 1 })) - .filter(({ line }) => REQUEST_SIGNAL.test(line)) + .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('/*')); @@ -75,6 +93,6 @@ describe('disconnect-immunity (AC7)', () => { join(WEB_SRC, 'app/api/ai/chat/stream-join/[messageId]/route.ts'), 'utf8', ); - expect(REQUEST_SIGNAL.test(source)).toBe(true); + expect(readsRequestSignal(source)).toBe(true); }); }); From 8a33d99976141a6098b4511a4fecd96572886cd0 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 12 Jul 2026 01:53:56 -0500 Subject: [PATCH 5/9] fix(ai): Stop did nothing at all before the response headers arrived MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial pass found a runaway I had looked at earlier and WRONGLY dismissed. streamId and messageId are BOTH minted server-side, and the client learns neither until the response headers land (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 presses it — and the client had NOTHING to name. `abortActiveStream` was a guaranteed map miss, so it cancelled the local fetch and returned. The button flipped back to Send. The user believed it worked. Cancelling the fetch stops NOTHING. Streams are deliberately server-owned and survive a client disconnect — that is the entire architecture. So the generation kept running, kept calling write tools, and kept BILLING, while the UI said it had stopped. One click, every time, in a multi-second window. I had traced this window earlier and concluded "there is no id the client could name." That was wrong. There is one: the CONVERSATION id, which the client holds from t=0 — and the server already aborts by conversation (takeOverConversationStreams does exactly that). I dismissed a real runaway on a claim I had not actually checked. /api/ai/abort now accepts conversationId as a fallback (messageId and streamId still take precedence — they name the stream exactly, whereas conversationId names all of them). AUTHZ IS 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. Naming someone else's conversation cannot kill their agent mid-answer. Pinned by 11 tests. The authz one is verified by removing the caller-scoping — i.e. adopting the takeover's rule — and watching it fail. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ie3Bmn3sgiUJ7a6ErwEc4 --- .../app/api/ai/abort/__tests__/route.test.ts | 71 +++++++++- apps/web/src/app/api/ai/abort/route.ts | 32 ++++- .../page-views/ai-page/AiChatView.tsx | 14 +- .../abort-conversation-streams.test.ts | 134 ++++++++++++++++++ .../lib/ai/core/abort-conversation-streams.ts | 81 +++++++++++ apps/web/src/lib/ai/core/client.ts | 1 + .../src/lib/ai/core/stream-abort-client.ts | 45 ++++++ 7 files changed, 363 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/lib/ai/core/__tests__/abort-conversation-streams.test.ts create mode 100644 apps/web/src/lib/ai/core/abort-conversation-streams.ts 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/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 42374b88cd..51e1344f6c 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 @@ -688,11 +688,15 @@ const AiChatView: React.FC = ({ page }) => { 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 }); + // 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); 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/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 ff7c9dbd4e..b841a49cfc 100644 --- a/apps/web/src/lib/ai/core/stream-abort-client.ts +++ b/apps/web/src/lib/ai/core/stream-abort-client.ts @@ -59,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' }; } From 16394c9b886b48704cac9431fc7a01733cfbf637 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 12 Jul 2026 02:43:40 -0500 Subject: [PATCH 6/9] lint(ai): exhaustive-deps is an ERROR on the stream surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stale closure in these files 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 that names it correctly. Every stale value here — a captured conversationId, a captured messageId, a captured isStreaming — means Stop names the wrong stream or nothing at all: 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 in this work. 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 pointed straight at a missing dep in SidebarChatTab's handleStop. The signal existed and was wired to nothing. That is the same failure as the six tests that could not fail, the CI that never ran on the stacked PR, and the migration whose lock risk no test could see. Promoted to an error for the ten files that own stream identity. It immediately found four violations I had confidently measured as zero: - AiChatView: `attachments.length` was a redundant dep (getFilesForSend is already memoized on [attachments] and is already listed). - GlobalChatContext: three useCallbacks omitted `dispatchIdentity`, and the context-value useMemo omitted `setCurrentConversationId`. Both are stable useCallback identities, so listing them is a runtime no-op — but the omission is exactly the habit that lets a NON-stable value slip through unnoticed in this subsystem. I had run `next lint --file` over these files, got zero, and reported it as free. It was not. The real lint run disagreed. Measuring with the wrong tool is how you get a green light that means nothing — which is the entire lesson of this PR. If this rule fires, do not silence it. A dep you are tempted to omit here is a stop button you are about to break. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ie3Bmn3sgiUJ7a6ErwEc4 --- apps/web/eslint.config.mjs | 35 +++++++++++++++++++ .../page-views/ai-page/AiChatView.tsx | 4 ++- apps/web/src/contexts/GlobalChatContext.tsx | 7 ++-- 3 files changed, 42 insertions(+), 4 deletions(-) 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/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 51e1344f6c..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 @@ -982,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, diff --git a/apps/web/src/contexts/GlobalChatContext.tsx b/apps/web/src/contexts/GlobalChatContext.tsx index bac3c00769..141a9d5fac 100644 --- a/apps/web/src/contexts/GlobalChatContext.tsx +++ b/apps/web/src/contexts/GlobalChatContext.tsx @@ -169,7 +169,7 @@ export function GlobalChatProvider({ children }: { children: ReactNode }) { setInitialMessages([]); setIsMessagesLoading(false); } - }, []); + }, [dispatchIdentity]); const createNewConversation = useCallback(async () => { try { @@ -187,7 +187,7 @@ export function GlobalChatProvider({ children }: { children: ReactNode }) { } catch (error) { console.error('Failed to create new conversation:', error); } - }, []); + }, [dispatchIdentity]); useEffect(() => { const initializeGlobalChat = async () => { @@ -452,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 @@ -470,6 +470,7 @@ export function GlobalChatProvider({ children }: { children: ReactNode }) { rejoinGlobalStream, latestGlobalConversationAdded, }), [ + setCurrentConversationId, currentConversationId, initialMessages, isInitialized, From 96fb08076dd98fe7e264dd174085819b82e3763c Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 12 Jul 2026 02:56:35 -0500 Subject: [PATCH 7/9] docs(ai): the takeover comment promised a guarantee the code does not have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The call site said: "at most one may be generating on a conversation at a time." That is false. takeOverConversationStreams is a check-then-act with NO serialization — the SELECT inside it and the INSERT inside createStreamLifecycle 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. Exactly what the guard exists to prevent. This is the fourth comment in this work that asserted a property the code lacked: - the heartbeat docblock called the parts checkpoint a MITIGATION for the immortal-ghost problem, when the checkpoint WAS the hole; - holdForStream's docstring said the messageId is "captured when the first chunk arrives", which is the exact false premise that made Stop abort the previous turn's message; - resolveActiveAssistantMessageId's docstring called the submitted window one "where no assistant id exists yet" — true only on the very first turn; - and this one. Each was load-bearing. A comment that promises a guarantee the code does not have is worse than no comment: the next person reads it, trusts it, and builds on a false premise. That is not a documentation problem, it is the same failure as a test that cannot fail — a signal that looks like it attests to something it never examined. The race is NOT closed here, deliberately: 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 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 narrows the window rather than opening one. Now stated in both places a future engineer will actually look: the call site and the module. Comments only. No behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ie3Bmn3sgiUJ7a6ErwEc4 --- apps/web/src/app/api/ai/chat/route.ts | 25 ++++++++++++++++----- apps/web/src/lib/ai/core/stream-takeover.ts | 20 +++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/api/ai/chat/route.ts b/apps/web/src/app/api/ai/chat/route.ts index 89061260e9..2eb41fcc49 100644 --- a/apps/web/src/app/api/ai/chat/route.ts +++ b/apps/web/src/app/api/ai/chat/route.ts @@ -1193,12 +1193,25 @@ export async function POST(request: Request) { } } - // Per-conversation in-flight guard. A stream is a server-owned entity: at most - // one may be generating on a conversation at a time. 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. + // 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, diff --git a/apps/web/src/lib/ai/core/stream-takeover.ts b/apps/web/src/lib/ai/core/stream-takeover.ts index 00ebd93377..3817013c9a 100644 --- a/apps/web/src/lib/ai/core/stream-takeover.ts +++ b/apps/web/src/lib/ai/core/stream-takeover.ts @@ -9,6 +9,26 @@ 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: * From b4d2982e2e18de4fc5f492353fd6baf486e27e67 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 12 Jul 2026 02:58:51 -0500 Subject: [PATCH 8/9] docs(ai): the global route carried the same false guarantee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweeping the diff for absolute claims ("always", "never", "cannot", "guarantee", "at most one") found the chat route was not the only place asserting a property the code lacks — the global route said the same thing, word for word: "A stream is a server-owned entity: at most one may be generating on a conversation at a time." It is not. takeOverConversationStreams is a check-then-act with no serialization, so two near-simultaneous sends can both find nothing in flight and both proceed. I corrected the chat route and did not check its twin — the same incompleteness that produced half the bugs in this work. The other absolute claims in the diff were VERIFIED rather than assumed: - "a conversation may only ever be CREATED from a cuid" — holds: the create branch is gated on isCuid, and a legacy non-cuid id passes only when its row already exists. - "Never 409" — holds: there is no 409 on any stream path (the only matches are the comments saying so). That verification is the point. Four comments in this work promised guarantees the code did not have, each load-bearing, and each was believed by the next person to read it — including me. A claim in a comment is a signal like any other, and an unverified one is a signal that attests to nothing. Comments only. No behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ie3Bmn3sgiUJ7a6ErwEc4 --- .../src/app/api/ai/global/[id]/messages/route.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) 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 da55d95127..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 @@ -1087,11 +1087,16 @@ MENTION PROCESSING: }).catch(() => {}); } - // Same per-conversation in-flight guard as POST /api/ai/chat. A stream is a - // server-owned entity: at most one may be generating on a conversation at a time. - // Without this 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. + // 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, From 2c544d007b86389d5a51da312067a73e32f3a49e Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 12 Jul 2026 03:04:58 -0500 Subject: [PATCH 9/9] fix(ai): the takeover log claimed a takeover that never happened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extending the "signals that attest to nothing" audit from tests and comments to LOGS found one that lies at exactly the moment an operator most needs the truth. takeOverConversationStreams logged "took over in-flight stream(s) on this conversation" unconditionally — including when `aborted` and `reconciled` were BOTH empty. That case is not a no-op: it means 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. Two agents, two sets of tool calls, two bills. The `unstoppable` warn immediately above says so correctly. The info line then contradicted it in the same breath. An operator scanning messages during an incident would read "took over" and move on. Now it only claims a takeover when one actually happened. Pinned by a test that asserts the warn fires AND that nothing claims a takeover; verified by restoring the unconditional log and watching it fail. This is the same defect as the six tests that could not fail, the CI that never ran, the tripwire blind to the obvious break, the lint rule at warning level, and the five comments promising guarantees the code did not have. A log is a signal like any other, and one that misreports attests to nothing. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ie3Bmn3sgiUJ7a6ErwEc4 --- .../ai/core/__tests__/stream-takeover.test.ts | 60 ++++++++++++++++++- apps/web/src/lib/ai/core/stream-takeover.ts | 25 ++++++-- 2 files changed, 77 insertions(+), 8 deletions(-) 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 index 253bc883c2..fdc52c59c4 100644 --- a/apps/web/src/lib/ai/core/__tests__/stream-takeover.test.ts +++ b/apps/web/src/lib/ai/core/__tests__/stream-takeover.test.ts @@ -1,6 +1,15 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -const { mockSelectWhere, mockUpdateWhere, mockUpdateSet, mockAbortStreamByMessageId } = vi.hoisted(() => ({ +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(), @@ -34,7 +43,7 @@ vi.mock('@pagespace/db/schema/ai-streams', () => ({ })); vi.mock('@pagespace/lib/logging/logger-config', () => ({ - loggers: { ai: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } }, + loggers: { ai: { info: mockLoggerInfo, warn: mockLoggerWarn, error: vi.fn(), debug: vi.fn() } }, })); vi.mock('@/lib/ai/core/stream-abort-registry', () => ({ @@ -216,4 +225,51 @@ describe('takeOverConversationStreams', () => { }); }); + + // 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/stream-takeover.ts b/apps/web/src/lib/ai/core/stream-takeover.ts index 3817013c9a..76188a3b4d 100644 --- a/apps/web/src/lib/ai/core/stream-takeover.ts +++ b/apps/web/src/lib/ai/core/stream-takeover.ts @@ -161,12 +161,25 @@ export const takeOverConversationStreams = async ({ }); } - loggers.ai.info('AI Chat API: took over in-flight stream(s) on this conversation', { - conversationId, - channelId, - aborted, - reconciled: reconcile, - }); + // 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) {