@intelligo-dev/core 1.0.0-beta.1 → 1.0.0-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +6 -0
- package/README.md +102 -0
- package/dist/attachments/errors.d.ts +7 -0
- package/dist/attachments/errors.d.ts.map +1 -0
- package/dist/attachments/errors.js +11 -0
- package/dist/attachments/errors.js.map +1 -0
- package/dist/attachments/index.d.ts +8 -0
- package/dist/attachments/index.d.ts.map +1 -0
- package/dist/attachments/index.js +7 -0
- package/dist/attachments/index.js.map +1 -0
- package/dist/attachments/service.d.ts +45 -0
- package/dist/attachments/service.d.ts.map +1 -0
- package/dist/attachments/service.js +100 -0
- package/dist/attachments/service.js.map +1 -0
- package/dist/attachments/types.d.ts +6 -0
- package/dist/attachments/types.d.ts.map +1 -0
- package/dist/attachments/types.js +2 -0
- package/dist/attachments/types.js.map +1 -0
- package/dist/conversations/errors.d.ts +2 -8
- package/dist/conversations/errors.d.ts.map +1 -1
- package/dist/conversations/errors.js +2 -8
- package/dist/conversations/errors.js.map +1 -1
- package/dist/conversations/index.d.ts +4 -12
- package/dist/conversations/index.d.ts.map +1 -1
- package/dist/conversations/index.js +2 -11
- package/dist/conversations/index.js.map +1 -1
- package/dist/conversations/service.d.ts +49 -61
- package/dist/conversations/service.d.ts.map +1 -1
- package/dist/conversations/service.js +110 -83
- package/dist/conversations/service.js.map +1 -1
- package/dist/conversations/types.d.ts +1 -4
- package/dist/conversations/types.d.ts.map +1 -1
- package/dist/conversations/types.js +0 -3
- package/dist/conversations/types.js.map +1 -1
- package/dist/db/client.d.ts +21 -10
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +43 -18
- package/dist/db/client.js.map +1 -1
- package/dist/db/index.d.ts +3 -3
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +3 -7
- package/dist/db/index.js.map +1 -1
- package/dist/db/schema/agents.d.ts +14 -51
- package/dist/db/schema/agents.d.ts.map +1 -1
- package/dist/db/schema/agents.js +9 -21
- package/dist/db/schema/agents.js.map +1 -1
- package/dist/db/schema/ai.d.ts +86 -1556
- package/dist/db/schema/ai.d.ts.map +1 -1
- package/dist/db/schema/ai.js +32 -236
- package/dist/db/schema/ai.js.map +1 -1
- package/dist/db/schema/auth.d.ts +5 -34
- package/dist/db/schema/auth.d.ts.map +1 -1
- package/dist/db/schema/auth.js +25 -56
- package/dist/db/schema/auth.js.map +1 -1
- package/dist/db/schema/billing.d.ts +136 -107
- package/dist/db/schema/billing.d.ts.map +1 -1
- package/dist/db/schema/billing.js +72 -60
- package/dist/db/schema/billing.js.map +1 -1
- package/dist/db/schema/feature-flags.d.ts +3 -12
- package/dist/db/schema/feature-flags.d.ts.map +1 -1
- package/dist/db/schema/feature-flags.js +3 -12
- package/dist/db/schema/feature-flags.js.map +1 -1
- package/dist/db/schema/identity.d.ts +12 -274
- package/dist/db/schema/identity.d.ts.map +1 -1
- package/dist/db/schema/identity.js +19 -104
- package/dist/db/schema/identity.js.map +1 -1
- package/dist/db/schema/index.d.ts +8 -15
- package/dist/db/schema/index.d.ts.map +1 -1
- package/dist/db/schema/index.js +12 -42
- package/dist/db/schema/index.js.map +1 -1
- package/dist/db/schema/notifications.d.ts +3 -17
- package/dist/db/schema/notifications.d.ts.map +1 -1
- package/dist/db/schema/notifications.js +3 -17
- package/dist/db/schema/notifications.js.map +1 -1
- package/dist/db/schema/usage.d.ts +94 -77
- package/dist/db/schema/usage.d.ts.map +1 -1
- package/dist/db/schema/usage.js +59 -89
- package/dist/db/schema/usage.js.map +1 -1
- package/dist/db/validate.d.ts +2 -11
- package/dist/db/validate.d.ts.map +1 -1
- package/dist/db/validate.js +6 -17
- package/dist/db/validate.js.map +1 -1
- package/dist/db/workspace-queries.d.ts +5 -29
- package/dist/db/workspace-queries.d.ts.map +1 -1
- package/dist/db/workspace-queries.js +5 -29
- package/dist/db/workspace-queries.js.map +1 -1
- package/dist/documents/classifier.d.ts +5 -12
- package/dist/documents/classifier.d.ts.map +1 -1
- package/dist/documents/classifier.js +16 -16
- package/dist/documents/classifier.js.map +1 -1
- package/dist/documents/errors.d.ts +2 -10
- package/dist/documents/errors.d.ts.map +1 -1
- package/dist/documents/errors.js +2 -10
- package/dist/documents/errors.js.map +1 -1
- package/dist/documents/index.d.ts +4 -13
- package/dist/documents/index.d.ts.map +1 -1
- package/dist/documents/index.js +3 -12
- package/dist/documents/index.js.map +1 -1
- package/dist/documents/service.d.ts +19 -26
- package/dist/documents/service.d.ts.map +1 -1
- package/dist/documents/service.js +44 -38
- package/dist/documents/service.js.map +1 -1
- package/dist/documents/types.d.ts +6 -10
- package/dist/documents/types.d.ts.map +1 -1
- package/dist/documents/types.js +0 -3
- package/dist/documents/types.js.map +1 -1
- package/dist/email/brand.d.ts +17 -0
- package/dist/email/brand.d.ts.map +1 -0
- package/dist/email/brand.js +9 -0
- package/dist/email/brand.js.map +1 -0
- package/dist/email/index.d.ts +4 -4
- package/dist/email/index.js +4 -4
- package/dist/email/provider.d.ts +1 -7
- package/dist/email/provider.d.ts.map +1 -1
- package/dist/email/provider.js +10 -19
- package/dist/email/provider.js.map +1 -1
- package/dist/email/send.d.ts +12 -12
- package/dist/email/send.d.ts.map +1 -1
- package/dist/email/send.js +5 -19
- package/dist/email/send.js.map +1 -1
- package/dist/email/senders.d.ts +17 -30
- package/dist/email/senders.d.ts.map +1 -1
- package/dist/email/senders.js +33 -47
- package/dist/email/senders.js.map +1 -1
- package/dist/email/templates/base-layout.d.ts +1 -1
- package/dist/email/templates/base-layout.d.ts.map +1 -1
- package/dist/email/templates/base-layout.js +4 -6
- package/dist/email/templates/base-layout.js.map +1 -1
- package/dist/email/templates/index.d.ts +10 -10
- package/dist/email/templates/index.d.ts.map +1 -1
- package/dist/email/templates/index.js +10 -16
- package/dist/email/templates/index.js.map +1 -1
- package/dist/email/templates/password-reset.d.ts +2 -2
- package/dist/email/templates/password-reset.d.ts.map +1 -1
- package/dist/email/templates/password-reset.js +10 -22
- package/dist/email/templates/password-reset.js.map +1 -1
- package/dist/email/templates/payment-failed.d.ts +2 -2
- package/dist/email/templates/payment-failed.d.ts.map +1 -1
- package/dist/email/templates/payment-failed.js +11 -24
- package/dist/email/templates/payment-failed.js.map +1 -1
- package/dist/email/templates/quota-warning.d.ts +4 -4
- package/dist/email/templates/quota-warning.d.ts.map +1 -1
- package/dist/email/templates/quota-warning.js +19 -39
- package/dist/email/templates/quota-warning.js.map +1 -1
- package/dist/email/templates/subscription-confirmed.d.ts +2 -2
- package/dist/email/templates/subscription-confirmed.d.ts.map +1 -1
- package/dist/email/templates/subscription-confirmed.js +13 -28
- package/dist/email/templates/subscription-confirmed.js.map +1 -1
- package/dist/email/templates/trial-expiry.d.ts +2 -2
- package/dist/email/templates/trial-expiry.d.ts.map +1 -1
- package/dist/email/templates/trial-expiry.js +12 -36
- package/dist/email/templates/trial-expiry.js.map +1 -1
- package/dist/email/templates/trial-warning.d.ts +2 -2
- package/dist/email/templates/trial-warning.d.ts.map +1 -1
- package/dist/email/templates/trial-warning.js +15 -32
- package/dist/email/templates/trial-warning.js.map +1 -1
- package/dist/email/templates/verify-email.d.ts +2 -2
- package/dist/email/templates/verify-email.d.ts.map +1 -1
- package/dist/email/templates/verify-email.js +10 -22
- package/dist/email/templates/verify-email.js.map +1 -1
- package/dist/email/templates/welcome.d.ts +2 -2
- package/dist/email/templates/welcome.d.ts.map +1 -1
- package/dist/email/templates/welcome.js +13 -20
- package/dist/email/templates/welcome.js.map +1 -1
- package/dist/email/templates/workspace-invitation.d.ts +2 -2
- package/dist/email/templates/workspace-invitation.d.ts.map +1 -1
- package/dist/email/templates/workspace-invitation.js +12 -26
- package/dist/email/templates/workspace-invitation.js.map +1 -1
- package/dist/env.d.ts +5 -10
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +41 -31
- package/dist/env.js.map +1 -1
- package/dist/identity/audit.d.ts +11 -24
- package/dist/identity/audit.d.ts.map +1 -1
- package/dist/identity/audit.js +13 -27
- package/dist/identity/audit.js.map +1 -1
- package/dist/identity/errors.d.ts +4 -12
- package/dist/identity/errors.d.ts.map +1 -1
- package/dist/identity/errors.js +2 -8
- package/dist/identity/errors.js.map +1 -1
- package/dist/identity/index.d.ts +5 -13
- package/dist/identity/index.d.ts.map +1 -1
- package/dist/identity/index.js +4 -12
- package/dist/identity/index.js.map +1 -1
- package/dist/identity/service.d.ts +13 -47
- package/dist/identity/service.d.ts.map +1 -1
- package/dist/identity/service.js +31 -78
- package/dist/identity/service.js.map +1 -1
- package/dist/identity/types.d.ts +1 -4
- package/dist/identity/types.d.ts.map +1 -1
- package/dist/identity/types.js +0 -3
- package/dist/identity/types.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -10
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +7 -18
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +45 -49
- package/dist/logger.js.map +1 -1
- package/dist/money.d.ts +95 -0
- package/dist/money.d.ts.map +1 -0
- package/dist/money.js +208 -0
- package/dist/money.js.map +1 -0
- package/dist/notifications/index.d.ts +9 -43
- package/dist/notifications/index.d.ts.map +1 -1
- package/dist/notifications/index.js +9 -49
- package/dist/notifications/index.js.map +1 -1
- package/dist/notifications/triggers.d.ts +9 -32
- package/dist/notifications/triggers.d.ts.map +1 -1
- package/dist/notifications/triggers.js +21 -58
- package/dist/notifications/triggers.js.map +1 -1
- package/dist/notifications/types.d.ts +1 -10
- package/dist/notifications/types.d.ts.map +1 -1
- package/dist/notifications/types.js +0 -9
- package/dist/notifications/types.js.map +1 -1
- package/dist/prompt.d.ts +38 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +118 -0
- package/dist/prompt.js.map +1 -0
- package/dist/registry.d.ts +32 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +60 -0
- package/dist/registry.js.map +1 -0
- package/dist/request-context.d.ts +49 -0
- package/dist/request-context.d.ts.map +1 -0
- package/dist/request-context.js +100 -0
- package/dist/request-context.js.map +1 -0
- package/dist/storage/index.d.ts +65 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +109 -0
- package/dist/storage/index.js.map +1 -0
- package/package.json +74 -24
- package/src/attachments/errors.ts +18 -0
- package/src/attachments/index.ts +22 -0
- package/src/attachments/service.ts +178 -0
- package/src/attachments/types.ts +6 -0
- package/src/conversations/errors.ts +45 -0
- package/src/conversations/index.ts +39 -0
- package/src/conversations/service.ts +594 -0
- package/src/conversations/types.ts +15 -0
- package/src/db/client.ts +91 -0
- package/src/db/index.ts +3 -0
- package/src/db/migrations/0000_baseline.sql +652 -0
- package/src/db/migrations/0001_reconcile_chain_built_databases.sql +53 -0
- package/src/db/migrations/0002_webhook_claim_lease.sql +1 -0
- package/src/db/migrations/README.md +74 -36
- package/src/db/migrations/legacy-chain.json +197 -0
- package/src/db/migrations/meta/0000_snapshot.json +4182 -157
- package/src/db/migrations/meta/0002_snapshot.json +2478 -368
- package/src/db/migrations/meta/_journal.json +6 -279
- package/src/db/schema/agents.ts +57 -0
- package/src/db/schema/ai.ts +174 -0
- package/src/db/schema/auth.ts +183 -0
- package/src/db/schema/billing.ts +209 -0
- package/src/db/schema/feature-flags.ts +20 -0
- package/src/db/schema/identity.ts +250 -0
- package/src/db/schema/index.ts +12 -0
- package/src/db/schema/notifications.ts +31 -0
- package/src/db/schema/usage.ts +269 -0
- package/src/db/validate.ts +63 -0
- package/src/db/workspace-queries.ts +64 -0
- package/src/documents/classifier.ts +61 -0
- package/src/documents/errors.ts +44 -0
- package/src/documents/index.ts +24 -0
- package/src/documents/service.ts +256 -0
- package/src/documents/types.ts +24 -0
- package/src/email/brand.ts +24 -0
- package/src/email/index.ts +19 -0
- package/src/email/provider.ts +278 -0
- package/src/email/send.ts +169 -0
- package/src/email/senders.ts +292 -0
- package/src/email/templates/base-layout.tsx +145 -0
- package/src/email/templates/index.ts +28 -0
- package/src/email/templates/password-reset.tsx +72 -0
- package/src/email/templates/payment-failed.tsx +77 -0
- package/src/email/templates/quota-warning.tsx +118 -0
- package/src/email/templates/subscription-confirmed.tsx +91 -0
- package/src/email/templates/trial-expiry.tsx +124 -0
- package/src/email/templates/trial-warning.tsx +104 -0
- package/src/email/templates/verify-email.tsx +72 -0
- package/src/email/templates/welcome.tsx +64 -0
- package/src/email/templates/workspace-invitation.tsx +105 -0
- package/src/env.ts +180 -0
- package/src/identity/audit.ts +65 -0
- package/src/identity/errors.ts +44 -0
- package/src/identity/index.ts +26 -0
- package/src/identity/service.ts +202 -0
- package/src/identity/types.ts +28 -0
- package/src/index.ts +4 -0
- package/src/logger.ts +198 -0
- package/src/money.ts +270 -0
- package/src/notifications/index.ts +108 -0
- package/src/notifications/triggers.ts +158 -0
- package/src/notifications/types.ts +18 -0
- package/src/prompt.ts +142 -0
- package/src/registry.ts +75 -0
- package/src/request-context.ts +137 -0
- package/src/storage/index.ts +153 -0
- package/dist/db/schema/rag.d.ts +0 -171
- package/dist/db/schema/rag.d.ts.map +0 -1
- package/dist/db/schema/rag.js +0 -42
- package/dist/db/schema/rag.js.map +0 -1
- package/src/db/migrations/0000_sharp_night_nurse.sql +0 -52
- package/src/db/migrations/0001_hesitant_vapor.sql +0 -40
- package/src/db/migrations/0002_numerous_lady_vermin.sql +0 -235
- package/src/db/migrations/0003_mighty_jean_grey.sql +0 -37
- package/src/db/migrations/0004_cynical_grandmaster.sql +0 -2
- package/src/db/migrations/0005_careful_switch.sql +0 -8
- package/src/db/migrations/0006_tense_blue_marvel.sql +0 -16
- package/src/db/migrations/0007_next_squadron_sinister.sql +0 -1
- package/src/db/migrations/0008_powerful_scream.sql +0 -39
- package/src/db/migrations/0009_sad_ink.sql +0 -1
- package/src/db/migrations/0010_shocking_omega_flight.sql +0 -2
- package/src/db/migrations/0011_ambiguous_otto_octavius.sql +0 -151
- package/src/db/migrations/0011_hnsw_vector_index.sql +0 -17
- package/src/db/migrations/0012_user_profiles.sql +0 -30
- package/src/db/migrations/0013_trending_seasonal_columns.sql +0 -9
- package/src/db/migrations/0014_add_product_type_to_agents.sql +0 -6
- package/src/db/migrations/0015_new_v1_tables.sql +0 -149
- package/src/db/migrations/0016_document_types.sql +0 -30
- package/src/db/migrations/0017_document_metadata.sql +0 -2
- package/src/db/migrations/0018_identity_graph.sql +0 -200
- package/src/db/migrations/0019_db_driven_prompts.sql +0 -20
- package/src/db/migrations/0020_pending_extractions.sql +0 -35
- package/src/db/migrations/0021_rag_documents.sql +0 -39
- package/src/db/migrations/0022_user_quotas_usage_jsonb.sql +0 -70
- package/src/db/migrations/0023_user_facts_user_scoped_unique.sql +0 -17
- package/src/db/migrations/0024_user_memories_hnsw.sql +0 -23
- package/src/db/migrations/0025_audit_trigger_errcode.sql +0 -21
- package/src/db/migrations/0026_user_profile_snapshots_composite_pk.sql +0 -17
- package/src/db/migrations/0027_billing_settings_mnt_columns.sql +0 -54
- package/src/db/migrations/0028_trial_credits_normalized_email.sql +0 -37
- package/src/db/migrations/0029_usage_records_conversation_idx.sql +0 -13
- package/src/db/migrations/0030_pending_extractions_composite_idx.sql +0 -11
- package/src/db/migrations/0031_rag_documents_hnsw.sql +0 -16
- package/src/db/migrations/0032_rate_limit_unique_idx.sql +0 -16
- package/src/db/migrations/0033_pending_extractions_partial_idx.sql +0 -18
- package/src/db/migrations/0034_rate_limit_count.sql +0 -9
- package/src/db/migrations/0035_credit_reservations.sql +0 -24
- package/src/db/migrations/0036_executions_audit_jobs.sql +0 -96
- package/src/db/migrations/0037_rate_limit_minute_bucket.sql +0 -46
- package/src/db/migrations/0038_user_quotas_generic_usage.sql +0 -25
- package/src/db/migrations/0039_platform_admin_impersonation.sql +0 -27
- package/src/db/migrations/0040_ee_audit_chain.sql +0 -23
- package/src/db/migrations/meta/0001_snapshot.json +0 -631
- package/src/db/migrations/meta/0003_snapshot.json +0 -2488
- package/src/db/migrations/meta/0004_snapshot.json +0 -2500
- package/src/db/migrations/meta/0005_snapshot.json +0 -2548
- package/src/db/migrations/meta/0006_snapshot.json +0 -2650
- package/src/db/migrations/meta/0007_snapshot.json +0 -2657
- package/src/db/migrations/meta/0008_snapshot.json +0 -2719
- package/src/db/migrations/meta/0009_snapshot.json +0 -2726
- package/src/db/migrations/meta/0010_snapshot.json +0 -2739
- package/src/db/migrations/meta/0011_snapshot.json +0 -3886
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-only persistence for conversations, messages and votes.
|
|
3
|
+
*
|
|
4
|
+
* Title generation, windowing and summarization, agent validation and model
|
|
5
|
+
* selection belong to the product; this module takes an already-resolved
|
|
6
|
+
* agentId and modelId.
|
|
7
|
+
*
|
|
8
|
+
* Callers pass a resolved actor (workspaceId, userId); core cannot depend on
|
|
9
|
+
* `@intelligo-dev/auth`. Conversations are user-private within a workspace,
|
|
10
|
+
* so every query filters by both ids — the caller is never trusted to have
|
|
11
|
+
* done it. Failures throw `ConversationServiceError`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { and, asc, desc, eq, gt, inArray, lt, sql } from "drizzle-orm";
|
|
15
|
+
import { db } from "../db";
|
|
16
|
+
import { conversations, messages, votes } from "../db/schema";
|
|
17
|
+
import { ConversationServiceError } from "./errors";
|
|
18
|
+
import type {
|
|
19
|
+
Conversation,
|
|
20
|
+
ConversationActor,
|
|
21
|
+
InsertMessage,
|
|
22
|
+
Message,
|
|
23
|
+
Vote,
|
|
24
|
+
} from "./types";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns the conversation if the actor owns it. Throws
|
|
28
|
+
* ConversationServiceError("not_found") otherwise — another user's
|
|
29
|
+
* conversation is indistinguishable from a missing one, so an id never
|
|
30
|
+
* reveals existence across users.
|
|
31
|
+
*/
|
|
32
|
+
async function verifyConversation(
|
|
33
|
+
actor: ConversationActor,
|
|
34
|
+
id: string
|
|
35
|
+
): Promise<Conversation> {
|
|
36
|
+
const [conversation] = await db
|
|
37
|
+
.select()
|
|
38
|
+
.from(conversations)
|
|
39
|
+
.where(
|
|
40
|
+
and(
|
|
41
|
+
eq(conversations.id, id),
|
|
42
|
+
eq(conversations.workspaceId, actor.workspaceId),
|
|
43
|
+
eq(conversations.userId, actor.userId)
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
.limit(1);
|
|
47
|
+
|
|
48
|
+
if (!conversation) {
|
|
49
|
+
throw new ConversationServiceError("not_found", "Conversation not found");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return conversation;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Creates a conversation. `agentId` and `modelId` are opaque to this
|
|
57
|
+
* module; `id` defaults to a fresh UUID. An `id` the actor already owns
|
|
58
|
+
* returns that conversation; one another actor owns is `forbidden`.
|
|
59
|
+
*/
|
|
60
|
+
export async function createConversation(
|
|
61
|
+
actor: ConversationActor,
|
|
62
|
+
params: {
|
|
63
|
+
agentId: string;
|
|
64
|
+
modelId: string;
|
|
65
|
+
id?: string;
|
|
66
|
+
title?: string | null;
|
|
67
|
+
}
|
|
68
|
+
): Promise<Conversation> {
|
|
69
|
+
const id = params.id ?? crypto.randomUUID();
|
|
70
|
+
const [conversation] = await db
|
|
71
|
+
.insert(conversations)
|
|
72
|
+
.values({
|
|
73
|
+
id,
|
|
74
|
+
workspaceId: actor.workspaceId,
|
|
75
|
+
userId: actor.userId,
|
|
76
|
+
agentId: params.agentId,
|
|
77
|
+
modelId: params.modelId,
|
|
78
|
+
title: params.title ?? null,
|
|
79
|
+
})
|
|
80
|
+
.onConflictDoNothing({ target: conversations.id })
|
|
81
|
+
.returning();
|
|
82
|
+
|
|
83
|
+
if (conversation) return conversation;
|
|
84
|
+
|
|
85
|
+
// The id is taken: by the actor's own racing first turn, which is the
|
|
86
|
+
// conversation this call wanted, or by someone else's.
|
|
87
|
+
try {
|
|
88
|
+
return await verifyConversation(actor, id);
|
|
89
|
+
} catch {
|
|
90
|
+
throw new ConversationServiceError(
|
|
91
|
+
"forbidden",
|
|
92
|
+
"Conversation id belongs to another actor"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Get a single conversation, scoped to the actor. */
|
|
98
|
+
export async function getConversation(
|
|
99
|
+
actor: ConversationActor,
|
|
100
|
+
id: string
|
|
101
|
+
): Promise<Conversation> {
|
|
102
|
+
return verifyConversation(actor, id);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* List conversations for the actor, most recently updated first.
|
|
107
|
+
* Projects only the columns a sidebar/dashboard list needs.
|
|
108
|
+
*/
|
|
109
|
+
export async function listConversations(
|
|
110
|
+
actor: ConversationActor,
|
|
111
|
+
options?: { limit?: number; offset?: number }
|
|
112
|
+
): Promise<
|
|
113
|
+
Array<
|
|
114
|
+
Pick<Conversation, "id" | "title" | "agentId" | "updatedAt" | "metadata">
|
|
115
|
+
>
|
|
116
|
+
> {
|
|
117
|
+
const limit = options?.limit ?? 50;
|
|
118
|
+
const offset = options?.offset ?? 0;
|
|
119
|
+
|
|
120
|
+
return db
|
|
121
|
+
.select({
|
|
122
|
+
id: conversations.id,
|
|
123
|
+
title: conversations.title,
|
|
124
|
+
agentId: conversations.agentId,
|
|
125
|
+
updatedAt: conversations.updatedAt,
|
|
126
|
+
metadata: conversations.metadata,
|
|
127
|
+
})
|
|
128
|
+
.from(conversations)
|
|
129
|
+
.where(
|
|
130
|
+
and(
|
|
131
|
+
eq(conversations.workspaceId, actor.workspaceId),
|
|
132
|
+
eq(conversations.userId, actor.userId)
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
.orderBy(desc(conversations.updatedAt))
|
|
136
|
+
.limit(limit)
|
|
137
|
+
.offset(offset);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const MAX_HISTORY_LIMIT = 100;
|
|
141
|
+
|
|
142
|
+
/** Paginated conversation history with cursor-based pagination. */
|
|
143
|
+
export async function getConversationHistory(
|
|
144
|
+
actor: ConversationActor,
|
|
145
|
+
params: { limit: number; endingBefore?: string }
|
|
146
|
+
): Promise<{ chats: Conversation[]; hasMore: boolean }> {
|
|
147
|
+
const limit = Math.min(Math.max(1, params.limit), MAX_HISTORY_LIMIT);
|
|
148
|
+
|
|
149
|
+
let cursorUpdatedAt: Date | undefined;
|
|
150
|
+
if (params.endingBefore) {
|
|
151
|
+
// Scope the cursor lookup too: unscoped, it would answer "does
|
|
152
|
+
// this conversation id exist, and when was it updated?" for any
|
|
153
|
+
// id in any tenant — a cross-tenant existence oracle.
|
|
154
|
+
const [cursorConversation] = await db
|
|
155
|
+
.select({ updatedAt: conversations.updatedAt })
|
|
156
|
+
.from(conversations)
|
|
157
|
+
.where(
|
|
158
|
+
and(
|
|
159
|
+
eq(conversations.id, params.endingBefore),
|
|
160
|
+
eq(conversations.workspaceId, actor.workspaceId),
|
|
161
|
+
eq(conversations.userId, actor.userId)
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
.limit(1);
|
|
165
|
+
cursorUpdatedAt = cursorConversation?.updatedAt;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const result = await db
|
|
169
|
+
.select()
|
|
170
|
+
.from(conversations)
|
|
171
|
+
.where(
|
|
172
|
+
and(
|
|
173
|
+
eq(conversations.workspaceId, actor.workspaceId),
|
|
174
|
+
eq(conversations.userId, actor.userId),
|
|
175
|
+
...(cursorUpdatedAt
|
|
176
|
+
? [lt(conversations.updatedAt, cursorUpdatedAt)]
|
|
177
|
+
: [])
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
.orderBy(desc(conversations.updatedAt))
|
|
181
|
+
.limit(limit + 1);
|
|
182
|
+
|
|
183
|
+
const hasMore = result.length > limit;
|
|
184
|
+
const chats = hasMore ? result.slice(0, -1) : result;
|
|
185
|
+
|
|
186
|
+
return { chats, hasMore };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const MAX_TITLE_LENGTH = 300;
|
|
190
|
+
|
|
191
|
+
/** Rename a conversation. Throws on an empty title or a title that's too long. */
|
|
192
|
+
export async function renameConversation(
|
|
193
|
+
actor: ConversationActor,
|
|
194
|
+
id: string,
|
|
195
|
+
title: string
|
|
196
|
+
): Promise<Conversation> {
|
|
197
|
+
const trimmed = title.trim();
|
|
198
|
+
if (trimmed.length === 0) {
|
|
199
|
+
throw new ConversationServiceError(
|
|
200
|
+
"invalid_input",
|
|
201
|
+
"Title cannot be empty"
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (trimmed.length > MAX_TITLE_LENGTH) {
|
|
205
|
+
throw new ConversationServiceError(
|
|
206
|
+
"invalid_input",
|
|
207
|
+
`Title cannot exceed ${MAX_TITLE_LENGTH} characters`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await verifyConversation(actor, id);
|
|
212
|
+
|
|
213
|
+
const [updated] = await db
|
|
214
|
+
.update(conversations)
|
|
215
|
+
.set({ title: trimmed, updatedAt: new Date() })
|
|
216
|
+
.where(eq(conversations.id, id))
|
|
217
|
+
.returning();
|
|
218
|
+
|
|
219
|
+
if (!updated) {
|
|
220
|
+
throw new ConversationServiceError(
|
|
221
|
+
"database_error",
|
|
222
|
+
"Failed to rename conversation"
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return updated;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Shallow-merges a patch into the conversation's `metadata`: each top-level
|
|
231
|
+
* key in `patch` replaces the stored one, every other key stays.
|
|
232
|
+
*/
|
|
233
|
+
export async function updateConversationMetadata(
|
|
234
|
+
actor: ConversationActor,
|
|
235
|
+
id: string,
|
|
236
|
+
patch: Record<string, unknown>
|
|
237
|
+
): Promise<Conversation> {
|
|
238
|
+
await verifyConversation(actor, id);
|
|
239
|
+
|
|
240
|
+
const [updated] = await db
|
|
241
|
+
.update(conversations)
|
|
242
|
+
.set({
|
|
243
|
+
metadata: sql`coalesce(${conversations.metadata}, '{}'::jsonb) || ${JSON.stringify(patch)}::jsonb`,
|
|
244
|
+
updatedAt: new Date(),
|
|
245
|
+
})
|
|
246
|
+
.where(eq(conversations.id, id))
|
|
247
|
+
.returning();
|
|
248
|
+
|
|
249
|
+
if (!updated) {
|
|
250
|
+
throw new ConversationServiceError(
|
|
251
|
+
"database_error",
|
|
252
|
+
"Failed to update conversation metadata"
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return updated;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export type ConversationVisibility = "private" | "public";
|
|
260
|
+
|
|
261
|
+
/** Publish or unpublish a conversation. Public means readable by its id, by anyone. */
|
|
262
|
+
export async function setConversationVisibility(
|
|
263
|
+
actor: ConversationActor,
|
|
264
|
+
id: string,
|
|
265
|
+
visibility: ConversationVisibility
|
|
266
|
+
): Promise<Conversation> {
|
|
267
|
+
await verifyConversation(actor, id);
|
|
268
|
+
|
|
269
|
+
const [updated] = await db
|
|
270
|
+
.update(conversations)
|
|
271
|
+
.set({ visibility, updatedAt: new Date() })
|
|
272
|
+
.where(eq(conversations.id, id))
|
|
273
|
+
.returning();
|
|
274
|
+
|
|
275
|
+
if (!updated) {
|
|
276
|
+
throw new ConversationServiceError(
|
|
277
|
+
"database_error",
|
|
278
|
+
"Failed to update conversation visibility"
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return updated;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* A conversation its owner published, by id alone — no actor, because
|
|
287
|
+
* the reader has none. Everything else about the row stays private:
|
|
288
|
+
* only what a shared page shows is projected.
|
|
289
|
+
*/
|
|
290
|
+
export async function getPublicConversation(id: string): Promise<{
|
|
291
|
+
id: string;
|
|
292
|
+
title: string | null;
|
|
293
|
+
agentId: string;
|
|
294
|
+
createdAt: Date;
|
|
295
|
+
updatedAt: Date;
|
|
296
|
+
}> {
|
|
297
|
+
const [row] = await db
|
|
298
|
+
.select({
|
|
299
|
+
id: conversations.id,
|
|
300
|
+
title: conversations.title,
|
|
301
|
+
agentId: conversations.agentId,
|
|
302
|
+
createdAt: conversations.createdAt,
|
|
303
|
+
updatedAt: conversations.updatedAt,
|
|
304
|
+
})
|
|
305
|
+
.from(conversations)
|
|
306
|
+
.where(
|
|
307
|
+
and(eq(conversations.id, id), eq(conversations.visibility, "public"))
|
|
308
|
+
)
|
|
309
|
+
.limit(1);
|
|
310
|
+
|
|
311
|
+
if (!row) {
|
|
312
|
+
throw new ConversationServiceError("not_found", "Conversation not found");
|
|
313
|
+
}
|
|
314
|
+
return row;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The messages of a published conversation, oldest first. Empty when it is not public. */
|
|
318
|
+
export async function getPublicMessages(id: string): Promise<Message[]> {
|
|
319
|
+
await getPublicConversation(id);
|
|
320
|
+
return db
|
|
321
|
+
.select()
|
|
322
|
+
.from(messages)
|
|
323
|
+
.where(eq(messages.conversationId, id))
|
|
324
|
+
.orderBy(asc(messages.createdAt));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Delete a conversation and all its messages (CASCADE). */
|
|
328
|
+
export async function deleteConversation(
|
|
329
|
+
actor: ConversationActor,
|
|
330
|
+
id: string
|
|
331
|
+
): Promise<void> {
|
|
332
|
+
await verifyConversation(actor, id);
|
|
333
|
+
await db.delete(conversations).where(eq(conversations.id, id));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Delete all conversations for the actor (user-scoped within the workspace). */
|
|
337
|
+
export async function deleteAllConversations(
|
|
338
|
+
actor: ConversationActor
|
|
339
|
+
): Promise<{ deletedCount: number }> {
|
|
340
|
+
const deleted = await db
|
|
341
|
+
.delete(conversations)
|
|
342
|
+
.where(
|
|
343
|
+
and(
|
|
344
|
+
eq(conversations.workspaceId, actor.workspaceId),
|
|
345
|
+
eq(conversations.userId, actor.userId)
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
.returning();
|
|
349
|
+
|
|
350
|
+
return { deletedCount: deleted.length };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Get all messages for a conversation, ordered oldest-first. */
|
|
354
|
+
export async function getMessages(
|
|
355
|
+
actor: ConversationActor,
|
|
356
|
+
conversationId: string
|
|
357
|
+
): Promise<Message[]> {
|
|
358
|
+
await verifyConversation(actor, conversationId);
|
|
359
|
+
|
|
360
|
+
return db
|
|
361
|
+
.select()
|
|
362
|
+
.from(messages)
|
|
363
|
+
.where(eq(messages.conversationId, conversationId))
|
|
364
|
+
.orderBy(asc(messages.createdAt));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Saves a batch of messages. Every `conversationId` must belong to a
|
|
369
|
+
* conversation the actor owns — a workspace-only guard would let any member
|
|
370
|
+
* write into a colleague's private conversation. Bumps `updatedAt` on the
|
|
371
|
+
* touched conversations.
|
|
372
|
+
*/
|
|
373
|
+
export async function saveMessages(
|
|
374
|
+
actor: ConversationActor,
|
|
375
|
+
messagesToSave: InsertMessage[]
|
|
376
|
+
): Promise<Message[]> {
|
|
377
|
+
if (messagesToSave.length === 0) return [];
|
|
378
|
+
|
|
379
|
+
const conversationIds = [
|
|
380
|
+
...new Set(messagesToSave.map((m) => m.conversationId)),
|
|
381
|
+
];
|
|
382
|
+
|
|
383
|
+
const ownedConversations = await db
|
|
384
|
+
.select({ id: conversations.id })
|
|
385
|
+
.from(conversations)
|
|
386
|
+
.where(
|
|
387
|
+
and(
|
|
388
|
+
eq(conversations.workspaceId, actor.workspaceId),
|
|
389
|
+
eq(conversations.userId, actor.userId),
|
|
390
|
+
inArray(conversations.id, conversationIds)
|
|
391
|
+
)
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
const ownedIds = new Set(ownedConversations.map((c) => c.id));
|
|
395
|
+
const unowned = messagesToSave.filter((m) => !ownedIds.has(m.conversationId));
|
|
396
|
+
if (unowned.length > 0) {
|
|
397
|
+
throw new ConversationServiceError(
|
|
398
|
+
"forbidden",
|
|
399
|
+
"One or more messages reference a conversation the actor does not own"
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// A batch arrives in conversation order, but `defaultNow()` would
|
|
404
|
+
// stamp every row with the same transaction time, and a tie in
|
|
405
|
+
// created_at makes "the messages after this one" undefined — a
|
|
406
|
+
// regenerate could then delete nothing or everything. One millisecond
|
|
407
|
+
// per position keeps the order the caller gave.
|
|
408
|
+
const base = Date.now();
|
|
409
|
+
const stamped = messagesToSave.map((message, index) => ({
|
|
410
|
+
...message,
|
|
411
|
+
createdAt: message.createdAt ?? new Date(base + index),
|
|
412
|
+
}));
|
|
413
|
+
|
|
414
|
+
const inserted = await db.insert(messages).values(stamped).returning();
|
|
415
|
+
|
|
416
|
+
await db
|
|
417
|
+
.update(conversations)
|
|
418
|
+
.set({ updatedAt: new Date() })
|
|
419
|
+
.where(inArray(conversations.id, conversationIds));
|
|
420
|
+
|
|
421
|
+
return inserted;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Idempotently upserts streamed messages. Takes no actor: the caller
|
|
426
|
+
* verified ownership once at the top of the already-authorized request.
|
|
427
|
+
*
|
|
428
|
+
* One multi-row `INSERT ... ON CONFLICT (id) DO UPDATE`, so an N-message
|
|
429
|
+
* tool loop pays one round-trip. The `WHERE` on the update keeps the write
|
|
430
|
+
* scoped to `conversationId` even if a message id collided across
|
|
431
|
+
* conversations.
|
|
432
|
+
*/
|
|
433
|
+
export async function upsertMessages(
|
|
434
|
+
conversationId: string,
|
|
435
|
+
finishedMessages: Array<{ id: string; role: string; parts: unknown[] }>
|
|
436
|
+
): Promise<void> {
|
|
437
|
+
if (finishedMessages.length === 0) return;
|
|
438
|
+
|
|
439
|
+
// Increment per row so `ORDER BY created_at` (used by getMessages)
|
|
440
|
+
// preserves insertion order across a batched tool loop. Sharing a
|
|
441
|
+
// single `new Date()` across rows would tie their timestamps and
|
|
442
|
+
// let Postgres return them in heap order.
|
|
443
|
+
const baseMs = Date.now();
|
|
444
|
+
const rows = finishedMessages.map((m, i) => ({
|
|
445
|
+
id: m.id,
|
|
446
|
+
conversationId,
|
|
447
|
+
role: m.role,
|
|
448
|
+
parts: JSON.stringify(m.parts),
|
|
449
|
+
createdAt: new Date(baseMs + i),
|
|
450
|
+
}));
|
|
451
|
+
|
|
452
|
+
await db
|
|
453
|
+
.insert(messages)
|
|
454
|
+
.values(rows)
|
|
455
|
+
.onConflictDoUpdate({
|
|
456
|
+
target: messages.id,
|
|
457
|
+
set: {
|
|
458
|
+
parts: sql`excluded.parts`,
|
|
459
|
+
},
|
|
460
|
+
setWhere: sql`${messages.conversationId} = excluded.conversation_id`,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Update a message's parts (e.g. for a tool-approval flow). */
|
|
465
|
+
export async function updateMessage(
|
|
466
|
+
actor: ConversationActor,
|
|
467
|
+
params: { id: string; parts: unknown }
|
|
468
|
+
): Promise<Message> {
|
|
469
|
+
const [message] = await db
|
|
470
|
+
.select()
|
|
471
|
+
.from(messages)
|
|
472
|
+
.where(eq(messages.id, params.id))
|
|
473
|
+
.limit(1);
|
|
474
|
+
|
|
475
|
+
if (!message) {
|
|
476
|
+
throw new ConversationServiceError("not_found", "Message not found");
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
await verifyConversation(actor, message.conversationId);
|
|
480
|
+
|
|
481
|
+
const [updated] = await db
|
|
482
|
+
.update(messages)
|
|
483
|
+
.set({ parts: JSON.stringify(params.parts) })
|
|
484
|
+
.where(eq(messages.id, params.id))
|
|
485
|
+
.returning();
|
|
486
|
+
|
|
487
|
+
if (!updated) {
|
|
488
|
+
throw new ConversationServiceError(
|
|
489
|
+
"database_error",
|
|
490
|
+
"Failed to update message"
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return updated;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Delete all messages created after a specific message. */
|
|
498
|
+
export async function deleteTrailingMessages(
|
|
499
|
+
actor: ConversationActor,
|
|
500
|
+
params: { id: string }
|
|
501
|
+
): Promise<{ deletedCount: number }> {
|
|
502
|
+
const [message] = await db
|
|
503
|
+
.select()
|
|
504
|
+
.from(messages)
|
|
505
|
+
.where(eq(messages.id, params.id))
|
|
506
|
+
.limit(1);
|
|
507
|
+
|
|
508
|
+
if (!message) {
|
|
509
|
+
throw new ConversationServiceError("not_found", "Message not found");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
await verifyConversation(actor, message.conversationId);
|
|
513
|
+
|
|
514
|
+
// Compare inside SQL rather than against the JS Date read above: the
|
|
515
|
+
// driver truncates Postgres microseconds to milliseconds, so a message
|
|
516
|
+
// whose real timestamp is 12:00:00.123456 reads back as .123, and
|
|
517
|
+
// `created_at > .123` then matches the message itself — deleting the
|
|
518
|
+
// row a regenerate was supposed to keep.
|
|
519
|
+
const deleted = await db
|
|
520
|
+
.delete(messages)
|
|
521
|
+
.where(
|
|
522
|
+
and(
|
|
523
|
+
eq(messages.conversationId, message.conversationId),
|
|
524
|
+
gt(
|
|
525
|
+
messages.createdAt,
|
|
526
|
+
sql`(select ${messages.createdAt} from ${messages} where ${messages.id} = ${params.id})`
|
|
527
|
+
)
|
|
528
|
+
)
|
|
529
|
+
)
|
|
530
|
+
.returning();
|
|
531
|
+
|
|
532
|
+
return { deletedCount: deleted.length };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Vote on a message (upvote or downvote). */
|
|
536
|
+
export async function voteMessage(
|
|
537
|
+
actor: ConversationActor,
|
|
538
|
+
params: { chatId: string; messageId: string; type: "up" | "down" }
|
|
539
|
+
): Promise<void> {
|
|
540
|
+
await verifyConversation(actor, params.chatId);
|
|
541
|
+
|
|
542
|
+
const [message] = await db
|
|
543
|
+
.select({ id: messages.id })
|
|
544
|
+
.from(messages)
|
|
545
|
+
.where(
|
|
546
|
+
and(
|
|
547
|
+
eq(messages.id, params.messageId),
|
|
548
|
+
eq(messages.conversationId, params.chatId)
|
|
549
|
+
)
|
|
550
|
+
)
|
|
551
|
+
.limit(1);
|
|
552
|
+
|
|
553
|
+
if (!message) {
|
|
554
|
+
throw new ConversationServiceError("not_found", "Message not found");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
await db
|
|
558
|
+
.insert(votes)
|
|
559
|
+
.values({
|
|
560
|
+
chatId: params.chatId,
|
|
561
|
+
messageId: params.messageId,
|
|
562
|
+
isUpvoted: params.type === "up",
|
|
563
|
+
})
|
|
564
|
+
.onConflictDoUpdate({
|
|
565
|
+
target: [votes.chatId, votes.messageId],
|
|
566
|
+
set: { isUpvoted: params.type === "up" },
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Withdraw a vote. A missing vote is not an error. */
|
|
571
|
+
export async function clearVote(
|
|
572
|
+
actor: ConversationActor,
|
|
573
|
+
params: { chatId: string; messageId: string }
|
|
574
|
+
): Promise<void> {
|
|
575
|
+
await verifyConversation(actor, params.chatId);
|
|
576
|
+
await db
|
|
577
|
+
.delete(votes)
|
|
578
|
+
.where(
|
|
579
|
+
and(
|
|
580
|
+
eq(votes.chatId, params.chatId),
|
|
581
|
+
eq(votes.messageId, params.messageId)
|
|
582
|
+
)
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Get all votes for a conversation. */
|
|
587
|
+
export async function getVotes(
|
|
588
|
+
actor: ConversationActor,
|
|
589
|
+
chatId: string
|
|
590
|
+
): Promise<Vote[]> {
|
|
591
|
+
await verifyConversation(actor, chatId);
|
|
592
|
+
|
|
593
|
+
return db.select().from(votes).where(eq(votes.chatId, chatId));
|
|
594
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Resolved actor identity — every query is scoped to this pair. */
|
|
2
|
+
export type ConversationActor = {
|
|
3
|
+
workspaceId: string;
|
|
4
|
+
userId: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type {
|
|
8
|
+
Conversation,
|
|
9
|
+
InsertConversation,
|
|
10
|
+
Message,
|
|
11
|
+
InsertMessage,
|
|
12
|
+
Vote,
|
|
13
|
+
InsertVote,
|
|
14
|
+
ConversationMetadata,
|
|
15
|
+
} from "../db/schema";
|
package/src/db/client.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One Drizzle handle, two drivers: `pg` (node-postgres) for ordinary
|
|
3
|
+
* Postgres URLs, and `neon-serverless` (Neon's WebSocket driver) for Neon
|
|
4
|
+
* URLs.
|
|
5
|
+
*
|
|
6
|
+
* Neon's HTTP driver is deliberately not an option: it has no session, so
|
|
7
|
+
* `db.transaction()` throws, and quota admission (`pg_advisory_xact_lock` +
|
|
8
|
+
* reservation insert) and usage settlement are transactions.
|
|
9
|
+
*
|
|
10
|
+
* `INTELLIGO_DB_DRIVER=pg | neon-serverless` forces a driver, for a
|
|
11
|
+
* Neon-compatible proxy or a self-hosted Postgres whose hostname happens to
|
|
12
|
+
* match.
|
|
13
|
+
*
|
|
14
|
+
* The client is created on first access so `next build` can collect route
|
|
15
|
+
* metadata without DATABASE_URL; a missing URL throws on the first query.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { drizzle as drizzleNeon } from "drizzle-orm/neon-serverless";
|
|
19
|
+
import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres";
|
|
20
|
+
import { Pool as NeonPool, neonConfig } from "@neondatabase/serverless";
|
|
21
|
+
import pkg from "pg";
|
|
22
|
+
const { Pool } = pkg;
|
|
23
|
+
import * as schema from "./schema";
|
|
24
|
+
|
|
25
|
+
export type DbDriver = "pg" | "neon-serverless";
|
|
26
|
+
|
|
27
|
+
type Db = ReturnType<typeof drizzleNeon> | ReturnType<typeof drizzlePostgres>;
|
|
28
|
+
|
|
29
|
+
let cached: Db | null = null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Which driver a connection string gets. Pure, so the rule is testable
|
|
33
|
+
* without a database: an explicit `INTELLIGO_DB_DRIVER` wins; otherwise
|
|
34
|
+
* Neon hosts (`neon.tech`) and Neon endpoint ids (`@ep-…`) take the
|
|
35
|
+
* WebSocket driver and everything else takes node-postgres.
|
|
36
|
+
*/
|
|
37
|
+
export function selectDriver(url: string, override?: string): DbDriver {
|
|
38
|
+
if (override === "pg" || override === "neon-serverless") return override;
|
|
39
|
+
if (override) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`INTELLIGO_DB_DRIVER must be "pg" or "neon-serverless", got "${override}". ` +
|
|
42
|
+
`(neon-http is not supported: it cannot run transactions.)`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const isNeon = url.includes("neon.tech") || url.includes("@ep-");
|
|
46
|
+
return isNeon ? "neon-serverless" : "pg";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function initDb(): Db {
|
|
50
|
+
const url = process.env.DATABASE_URL;
|
|
51
|
+
if (!url) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
"DATABASE_URL is not defined. Please set it in your .env file."
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const driver = selectDriver(url, process.env.INTELLIGO_DB_DRIVER);
|
|
58
|
+
|
|
59
|
+
if (driver === "neon-serverless") {
|
|
60
|
+
// The WebSocket driver needs a WebSocket implementation. Node 22+
|
|
61
|
+
// has one globally; older runtimes must install `ws` and assign
|
|
62
|
+
// `neonConfig.webSocketConstructor` themselves before first use.
|
|
63
|
+
if (!neonConfig.webSocketConstructor && typeof WebSocket !== "undefined") {
|
|
64
|
+
neonConfig.webSocketConstructor = WebSocket;
|
|
65
|
+
}
|
|
66
|
+
const pool = new NeonPool({ connectionString: url });
|
|
67
|
+
console.log("📊 Database: Neon (serverless, WebSocket)");
|
|
68
|
+
return drizzleNeon(pool, { schema });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const pool = new Pool({ connectionString: url });
|
|
72
|
+
console.log("📊 Database: PostgreSQL");
|
|
73
|
+
return drizzlePostgres(pool as any, { schema });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveDb(): Db {
|
|
77
|
+
if (!cached) cached = initDb();
|
|
78
|
+
return cached;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Proxy keeps the ergonomic `db.select()...` shape for all call
|
|
82
|
+
// sites while deferring actual driver instantiation to first access.
|
|
83
|
+
// `next build` never touches these handlers, so module load is
|
|
84
|
+
// env-independent; first real request materializes the client.
|
|
85
|
+
export const db: Db = new Proxy({} as Db, {
|
|
86
|
+
get(_target, prop, receiver) {
|
|
87
|
+
const real = resolveDb();
|
|
88
|
+
const value = Reflect.get(real, prop, receiver);
|
|
89
|
+
return typeof value === "function" ? value.bind(real) : value;
|
|
90
|
+
},
|
|
91
|
+
}) as Db;
|
package/src/db/index.ts
ADDED