From c17d30fe98575c9e742bdca035e49aaee173cfe9 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 13 Oct 2026 00:17:04 +0200 Subject: [PATCH 1/8] feat(db): effect-orm table definitions beside the drizzle schema Every table as a PG.table in @hazel/db/tables, generated from drizzle's runtime table config and checked against it: the DDL they render, applied to an empty Postgres, pg_dumps identically to a drizzle-kit push, except that drizzle's UNIQUE constraints are unique indexes of the same name (effect-orm does not model unique constraints). Timestamps read as Date and jsonb documents stay unvalidated, as drizzle had them; ids are branded through the @hazel/schema schemas. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 4 + packages/db/package.json | 7 +- packages/db/scripts/render-ddl.ts | 14 ++ packages/db/src/tables/attachments.ts | 33 ++++ packages/db/src/tables/bot-commands.ts | 38 ++++ packages/db/src/tables/bot-installations.ts | 21 +++ packages/db/src/tables/bots.ts | 37 ++++ packages/db/src/tables/channel-access.ts | 22 +++ packages/db/src/tables/channel-sections.ts | 23 +++ packages/db/src/tables/channel-webhooks.ts | 33 ++++ packages/db/src/tables/channels.ts | 62 +++++++ packages/db/src/tables/chat-sync.ts | 169 ++++++++++++++++++ packages/db/src/tables/columns.ts | 50 ++++++ packages/db/src/tables/connect.ts | 142 +++++++++++++++ packages/db/src/tables/custom-emojis.ts | 27 +++ .../db/src/tables/github-subscriptions.ts | 37 ++++ packages/db/src/tables/index.ts | 28 +++ .../db/src/tables/integration-connections.ts | 51 ++++++ .../db/src/tables/integration-requests.ts | 32 ++++ packages/db/src/tables/integration-tokens.ts | 31 ++++ packages/db/src/tables/message-outbox.ts | 50 ++++++ packages/db/src/tables/messages.ts | 115 ++++++++++++ packages/db/src/tables/notifications.ts | 31 ++++ packages/db/src/tables/organizations.ts | 64 +++++++ packages/db/src/tables/pinned-messages.ts | 21 +++ packages/db/src/tables/rss-subscriptions.ts | 56 ++++++ packages/db/src/tables/typing-indicators.ts | 20 +++ .../db/src/tables/user-presence-status.ts | 35 ++++ packages/db/src/tables/users.ts | 33 ++++ 29 files changed, 1284 insertions(+), 2 deletions(-) create mode 100644 packages/db/scripts/render-ddl.ts create mode 100644 packages/db/src/tables/attachments.ts create mode 100644 packages/db/src/tables/bot-commands.ts create mode 100644 packages/db/src/tables/bot-installations.ts create mode 100644 packages/db/src/tables/bots.ts create mode 100644 packages/db/src/tables/channel-access.ts create mode 100644 packages/db/src/tables/channel-sections.ts create mode 100644 packages/db/src/tables/channel-webhooks.ts create mode 100644 packages/db/src/tables/channels.ts create mode 100644 packages/db/src/tables/chat-sync.ts create mode 100644 packages/db/src/tables/columns.ts create mode 100644 packages/db/src/tables/connect.ts create mode 100644 packages/db/src/tables/custom-emojis.ts create mode 100644 packages/db/src/tables/github-subscriptions.ts create mode 100644 packages/db/src/tables/index.ts create mode 100644 packages/db/src/tables/integration-connections.ts create mode 100644 packages/db/src/tables/integration-requests.ts create mode 100644 packages/db/src/tables/integration-tokens.ts create mode 100644 packages/db/src/tables/message-outbox.ts create mode 100644 packages/db/src/tables/messages.ts create mode 100644 packages/db/src/tables/notifications.ts create mode 100644 packages/db/src/tables/organizations.ts create mode 100644 packages/db/src/tables/pinned-messages.ts create mode 100644 packages/db/src/tables/rss-subscriptions.ts create mode 100644 packages/db/src/tables/typing-indicators.ts create mode 100644 packages/db/src/tables/user-presence-status.ts create mode 100644 packages/db/src/tables/users.ts diff --git a/bun.lock b/bun.lock index 7c6bf8377..a31596591 100644 --- a/bun.lock +++ b/bun.lock @@ -541,8 +541,10 @@ "name": "@hazel/db", "version": "0.0.0", "dependencies": { + "@effect/sql-pg": "catalog:effect", "@hazel/domain": "workspace:*", "@hazel/schema": "workspace:*", + "@maple-dev/effect-orm": "0.5.0", "drizzle-orm": "^0.45.1", "effect": "catalog:effect", "postgres": "^3.4.7", @@ -1405,6 +1407,8 @@ "@maple-dev/alchemy": ["@maple-dev/alchemy@0.2.0", "", { "dependencies": { "@maple-dev/effect-sdk": "0.10.0" }, "peerDependencies": { "alchemy": ">=2.0.0-beta.80 <3", "effect": "^4.0.0" } }, "sha512-lGjw1u0eAfNBKzp+IfcZHOJyqn2N3ivT0uGvrj8alK5vJsYwP1lI3y9A7rgTIyrgIsEswodTyUFduaZa9YjGkw=="], + "@maple-dev/effect-orm": ["@maple-dev/effect-orm@0.5.0", "", { "peerDependencies": { "effect": "^4.0.0" }, "bin": { "ch-bench": "dist/benchmark/bin.mjs", "effect-orm": "dist/kit/bin.mjs" } }, "sha512-DsjyJSHSX5xRhlVdTsPOci69MFNT6wuh0xXKnviYdsBgH7Il4zsB5v/95krbx9JSL2LKR0Q+5iBDZfdcPQLGCA=="], + "@maple-dev/effect-sdk": ["@maple-dev/effect-sdk@0.10.0", "", { "dependencies": { "std-env": "^4.0.0" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-3MWyBqWPyiizAZItDk7Uk+vi69csziIJxCcaqW10aowVV1Tlcwao1EW6xLccAzvfkz43Y8WAiLzhf/Kr4oRkdQ=="], "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], diff --git a/packages/db/package.json b/packages/db/package.json index bb190b750..0795d0ddc 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -6,7 +6,8 @@ "main": "src/index.ts", "exports": { ".": "./src/index.ts", - "./schema": "./src/schema/index.ts" + "./schema": "./src/schema/index.ts", + "./tables": "./src/tables/index.ts" }, "scripts": { "db": "drizzle-kit", @@ -21,7 +22,9 @@ "@hazel/schema": "workspace:*", "drizzle-orm": "^0.45.1", "effect": "catalog:effect", - "postgres": "^3.4.7" + "postgres": "^3.4.7", + "@maple-dev/effect-orm": "0.5.0", + "@effect/sql-pg": "catalog:effect" }, "devDependencies": { "@testcontainers/postgresql": "^10.18.0", diff --git a/packages/db/scripts/render-ddl.ts b/packages/db/scripts/render-ddl.ts new file mode 100644 index 000000000..480185f2e --- /dev/null +++ b/packages/db/scripts/render-ddl.ts @@ -0,0 +1,14 @@ +// Prints the DDL the effect-orm tables describe, enum types first. +import * as S from "@maple-dev/effect-orm/schema" +import { Effect } from "effect" +import * as Tables from "../src/tables" + +const enums = Object.values(Tables).filter( + (value): value is { enumName: string; enumValues: ReadonlyArray } => + typeof value === "object" && value !== null && "enumName" in value, +) +for (const e of enums) { + console.log(`CREATE TYPE "${e.enumName}" AS ENUM (${e.enumValues.map((v) => `'${v}'`).join(", ")});`) +} +const tables = Object.values(Tables).filter(S.isSchemaObject) +for (const statement of S.renderPgSchema(Effect.runSync(S.pgEntitiesOf(tables)))) console.log(`${statement};`) diff --git a/packages/db/src/tables/attachments.ts b/packages/db/src/tables/attachments.ts new file mode 100644 index 000000000..82597986f --- /dev/null +++ b/packages/db/src/tables/attachments.ts @@ -0,0 +1,33 @@ +import { AttachmentId, ChannelId, MessageId, OrganizationId, UserId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { pgEnum, timestamptz, varchar } from "./columns" + +export const attachmentStatusEnum = pgEnum("attachment_status", ["uploading", "complete", "failed"]) + +export const attachmentsTable = PG.table("attachments", { + columns: { + id: PG.column(PG.brand(PG.uuid, AttachmentId), { defaultExpr: "gen_random_uuid()" }), + organizationId: PG.brand(PG.uuid, OrganizationId), + channelId: PG.nullable(PG.brand(PG.uuid, ChannelId)), + messageId: PG.nullable(PG.brand(PG.uuid, MessageId)), + fileName: varchar(255), + fileSize: PG.int4, + externalUrl: PG.nullable(varchar(2048)), + uploadedBy: PG.brand(PG.uuid, UserId), + status: PG.column(attachmentStatusEnum, { default: "uploading" }), + uploadedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("attachments_organization_id_idx", ["organizationId"]), + PG.index("attachments_channel_id_idx", ["channelId"]), + PG.index("attachments_message_id_idx", ["messageId"]), + PG.index("attachments_message_uploaded_at_idx", ["messageId", "uploadedAt"]), + PG.index("attachments_uploaded_by_idx", ["uploadedBy"]), + PG.index("attachments_status_idx", ["status"]), + PG.index("attachments_deleted_at_idx", ["deletedAt"]), + ], +}) +export type Attachment = PG.SelectRowOf +export type NewAttachment = PG.InsertRowOf diff --git a/packages/db/src/tables/bot-commands.ts b/packages/db/src/tables/bot-commands.ts new file mode 100644 index 000000000..eeba08388 --- /dev/null +++ b/packages/db/src/tables/bot-commands.ts @@ -0,0 +1,38 @@ +import { BotCommandId, BotId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { jsonb, timestamptz, varchar } from "./columns" + +/** + * Argument definition for a bot command + */ +export interface BotCommandArgument { + name: string + description?: string + required: boolean + placeholder?: string + type: "string" | "number" | "user" | "channel" +} + +/** + * Bot commands table - stores slash commands registered by bots + */ +export const botCommandsTable = PG.table("bot_commands", { + columns: { + id: PG.column(PG.brand(PG.uuid, BotCommandId), { defaultExpr: "gen_random_uuid()" }), + botId: PG.brand(PG.uuid, BotId), + name: varchar(50), + description: PG.text, + arguments: PG.column(PG.nullable(jsonb()), { default: [] }), + usageExample: PG.nullable(PG.text), + isEnabled: PG.column(PG.bool, { default: true }), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + }, + primaryKey: ["id"], + indexes: [ + PG.index("bot_commands_bot_id_idx", ["botId"]), + PG.uniqueIndex("bot_commands_bot_id_name_idx", ["botId", "name"]), + ], +}) +export type BotCommand = PG.SelectRowOf +export type NewBotCommand = PG.InsertRowOf diff --git a/packages/db/src/tables/bot-installations.ts b/packages/db/src/tables/bot-installations.ts new file mode 100644 index 000000000..49dad1077 --- /dev/null +++ b/packages/db/src/tables/bot-installations.ts @@ -0,0 +1,21 @@ +import { BotId, BotInstallationId, OrganizationId, UserId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { timestamptz } from "./columns" + +export const botInstallationsTable = PG.table("bot_installations", { + columns: { + id: PG.column(PG.brand(PG.uuid, BotInstallationId), { defaultExpr: "gen_random_uuid()" }), + botId: PG.brand(PG.uuid, BotId), + organizationId: PG.brand(PG.uuid, OrganizationId), + installedBy: PG.brand(PG.uuid, UserId), + installedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + }, + primaryKey: ["id"], + indexes: [ + PG.index("bot_installations_org_idx", ["organizationId"]), + PG.index("bot_installations_bot_idx", ["botId"]), + PG.uniqueIndex("bot_installations_bot_org_unique", ["botId", "organizationId"]), + ], +}) +export type BotInstallation = PG.SelectRowOf +export type NewBotInstallation = PG.InsertRowOf diff --git a/packages/db/src/tables/bots.ts b/packages/db/src/tables/bots.ts new file mode 100644 index 000000000..4bbef8819 --- /dev/null +++ b/packages/db/src/tables/bots.ts @@ -0,0 +1,37 @@ +import { BotId, UserId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { jsonb, timestamptz, varchar } from "./columns" +import type { IntegrationConnection } from "./integration-connections" + +type IntegrationProvider = IntegrationConnection["provider"] + +export const botsTable = PG.table("bots", { + columns: { + id: PG.column(PG.brand(PG.uuid, BotId), { defaultExpr: "gen_random_uuid()" }), + userId: PG.brand(PG.uuid, UserId), + createdBy: PG.brand(PG.uuid, UserId), + name: varchar(100), + description: PG.nullable(PG.text), + webhookUrl: PG.nullable(PG.text), + apiTokenHash: PG.text, + scopes: PG.nullable(jsonb()), + metadata: PG.nullable(jsonb>()), + isPublic: PG.column(PG.bool, { default: false }), + installCount: PG.column(PG.int4, { default: 0 }), + mentionable: PG.column(PG.bool, { default: false }), + allowedIntegrations: PG.column(PG.nullable(jsonb()), { default: [] }), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.uniqueIndex("bots_userId_unique", ["userId"]), + PG.index("bots_user_id_idx", ["userId"]), + PG.index("bots_created_by_idx", ["createdBy"]), + PG.index("bots_is_public_idx", ["isPublic"]), + PG.index("bots_deleted_at_idx", ["deletedAt"]), + ], +}) +export type Bot = PG.SelectRowOf +export type NewBot = PG.InsertRowOf diff --git a/packages/db/src/tables/channel-access.ts b/packages/db/src/tables/channel-access.ts new file mode 100644 index 000000000..3f710bebd --- /dev/null +++ b/packages/db/src/tables/channel-access.ts @@ -0,0 +1,22 @@ +import { ChannelId, OrganizationId, UserId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { timestamptz } from "./columns" + +export const channelAccessTable = PG.table("channel_access", { + columns: { + userId: PG.brand(PG.uuid, UserId), + channelId: PG.brand(PG.uuid, ChannelId), + organizationId: PG.brand(PG.uuid, OrganizationId), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + }, + primaryKey: { columns: ["userId", "channelId"], name: "channel_access_user_channel_pk" }, + indexes: [ + PG.index("channel_access_user_id_idx", ["userId"]), + PG.index("channel_access_channel_id_idx", ["channelId"]), + PG.index("channel_access_organization_id_idx", ["organizationId"]), + PG.index("channel_access_org_user_idx", ["organizationId", "userId"]), + ], +}) +export type ChannelAccess = PG.SelectRowOf +export type NewChannelAccess = PG.InsertRowOf diff --git a/packages/db/src/tables/channel-sections.ts b/packages/db/src/tables/channel-sections.ts new file mode 100644 index 000000000..e96e093f3 --- /dev/null +++ b/packages/db/src/tables/channel-sections.ts @@ -0,0 +1,23 @@ +import { ChannelSectionId, OrganizationId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { timestamptz, varchar } from "./columns" + +export const channelSectionsTable = PG.table("channel_sections", { + columns: { + id: PG.column(PG.brand(PG.uuid, ChannelSectionId), { defaultExpr: "gen_random_uuid()" }), + organizationId: PG.brand(PG.uuid, OrganizationId), + name: varchar(255), + order: PG.column(PG.int4, { default: 0 }), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("channel_sections_organization_id_idx", ["organizationId"]), + PG.index("channel_sections_order_idx", ["organizationId", "order"]), + PG.index("channel_sections_deleted_at_idx", ["deletedAt"]), + ], +}) +export type ChannelSection = PG.SelectRowOf +export type NewChannelSection = PG.InsertRowOf diff --git a/packages/db/src/tables/channel-webhooks.ts b/packages/db/src/tables/channel-webhooks.ts new file mode 100644 index 000000000..ab69c0a25 --- /dev/null +++ b/packages/db/src/tables/channel-webhooks.ts @@ -0,0 +1,33 @@ +import { ChannelId, ChannelWebhookId, OrganizationId, UserId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { timestamptz, varchar } from "./columns" + +export const channelWebhooksTable = PG.table("channel_webhooks", { + columns: { + id: PG.column(PG.brand(PG.uuid, ChannelWebhookId), { defaultExpr: "gen_random_uuid()" }), + channelId: PG.brand(PG.uuid, ChannelId), + organizationId: PG.brand(PG.uuid, OrganizationId), + botUserId: PG.brand(PG.uuid, UserId), + name: varchar(100), + description: PG.nullable(PG.text), + avatarUrl: PG.nullable(PG.text), + tokenHash: PG.text, + tokenSuffix: varchar(4), + isEnabled: PG.column(PG.bool, { default: true }), + createdBy: PG.brand(PG.uuid, UserId), + lastUsedAt: PG.nullable(timestamptz), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("channel_webhooks_channel_id_idx", ["channelId"]), + PG.index("channel_webhooks_organization_id_idx", ["organizationId"]), + PG.index("channel_webhooks_bot_user_id_idx", ["botUserId"]), + PG.index("channel_webhooks_token_hash_idx", ["tokenHash"]), + PG.index("channel_webhooks_deleted_at_idx", ["deletedAt"]), + ], +}) +export type ChannelWebhook = PG.SelectRowOf +export type NewChannelWebhook = PG.InsertRowOf diff --git a/packages/db/src/tables/channels.ts b/packages/db/src/tables/channels.ts new file mode 100644 index 000000000..3d733cfb0 --- /dev/null +++ b/packages/db/src/tables/channels.ts @@ -0,0 +1,62 @@ +import { + ChannelIcon, + ChannelId, + ChannelMemberId, + ChannelSectionId, + MessageId, + OrganizationId, + UserId, +} from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { pgEnum, timestamptz, varchar } from "./columns" + +export const channelTypeEnum = pgEnum("channel_type", ["public", "private", "thread", "direct", "single"]) + +export const channelMembersTable = PG.table("channel_members", { + columns: { + id: PG.column(PG.brand(PG.uuid, ChannelMemberId), { defaultExpr: "gen_random_uuid()" }), + channelId: PG.brand(PG.uuid, ChannelId), + userId: PG.brand(PG.uuid, UserId), + isHidden: PG.column(PG.bool, { default: false }), + isMuted: PG.column(PG.bool, { default: false }), + isFavorite: PG.column(PG.bool, { default: false }), + lastSeenMessageId: PG.nullable(PG.brand(PG.uuid, MessageId)), + notificationCount: PG.column(PG.int4, { default: 0 }), + joinedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("channel_members_channel_id_idx", ["channelId"]), + PG.index("channel_members_user_id_idx", ["userId"]), + PG.index("channel_members_channel_user_idx", ["channelId", "userId"]), + ], +}) +export type ChannelMember = PG.SelectRowOf +export type NewChannelMember = PG.InsertRowOf + +export const channelsTable = PG.table("channels", { + columns: { + id: PG.column(PG.brand(PG.uuid, ChannelId), { defaultExpr: "gen_random_uuid()" }), + name: varchar(255), + icon: PG.nullable(PG.brand(varchar(32), ChannelIcon)), + type: channelTypeEnum, + organizationId: PG.brand(PG.uuid, OrganizationId), + parentChannelId: PG.nullable(PG.brand(PG.uuid, ChannelId)), + sectionId: PG.nullable(PG.brand(PG.uuid, ChannelSectionId)), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("channels_organization_id_idx", ["organizationId"]), + PG.index("channels_parent_channel_id_idx", ["parentChannelId"]), + PG.index("channels_section_id_idx", ["sectionId"]), + PG.index("channels_type_idx", ["type"]), + PG.index("channels_deleted_at_idx", ["deletedAt"]), + ], +}) +export type Channel = PG.SelectRowOf +export type NewChannel = PG.InsertRowOf diff --git a/packages/db/src/tables/chat-sync.ts b/packages/db/src/tables/chat-sync.ts new file mode 100644 index 000000000..fa0802c96 --- /dev/null +++ b/packages/db/src/tables/chat-sync.ts @@ -0,0 +1,169 @@ +import { + ChannelId, + IntegrationConnectionId, + MessageId, + OrganizationId, + SyncChannelLinkId, + SyncConnectionId, + SyncEventReceiptId, + SyncMessageLinkId, + UserId, +} from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { jsonb, pgEnum, timestamptz, varchar } from "./columns" + +export const chatSyncConnectionStatusEnum = pgEnum("chat_sync_connection_status", [ + "active", + "paused", + "error", + "disabled", +]) +export const chatSyncDirectionEnum = pgEnum("chat_sync_direction", [ + "both", + "hazel_to_external", + "external_to_hazel", +]) +export const chatSyncReceiptSourceEnum = pgEnum("chat_sync_receipt_source", ["hazel", "external"]) +export const chatSyncReceiptStatusEnum = pgEnum("chat_sync_receipt_status", [ + "processed", + "ignored", + "failed", +]) + +export const chatSyncChannelLinksTable = PG.table("chat_sync_channel_links", { + columns: { + id: PG.column(PG.brand(PG.uuid, SyncChannelLinkId), { defaultExpr: "gen_random_uuid()" }), + syncConnectionId: PG.brand(PG.uuid, SyncConnectionId), + hazelChannelId: PG.brand(PG.uuid, ChannelId), + externalChannelId: varchar(255), + externalChannelName: PG.nullable(varchar(255)), + direction: PG.column(chatSyncDirectionEnum, { default: "both" }), + isActive: PG.column(PG.bool, { default: true }), + settings: PG.nullable(jsonb>()), + lastSyncedAt: PG.nullable(timestamptz), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("chat_sync_link_connection_idx", ["syncConnectionId"]), + PG.index("chat_sync_link_hazel_channel_idx", ["hazelChannelId"]), + PG.index("chat_sync_link_external_channel_idx", ["externalChannelId"]), + PG.index("chat_sync_link_deleted_at_idx", ["deletedAt"]), + PG.uniqueIndex("chat_sync_link_connection_hazel_unique", ["syncConnectionId", "hazelChannelId"], { + where: '"deletedAt" IS NULL', + }), + PG.uniqueIndex( + "chat_sync_link_connection_external_unique", + ["syncConnectionId", "externalChannelId"], + { where: '"deletedAt" IS NULL' }, + ), + ], +}) +export type ChatSyncChannelLink = PG.SelectRowOf +export type NewChatSyncChannelLink = PG.InsertRowOf + +export const chatSyncConnectionsTable = PG.table("chat_sync_connections", { + columns: { + id: PG.column(PG.brand(PG.uuid, SyncConnectionId), { defaultExpr: "gen_random_uuid()" }), + organizationId: PG.brand(PG.uuid, OrganizationId), + integrationConnectionId: PG.nullable(PG.brand(PG.uuid, IntegrationConnectionId)), + provider: varchar(50), + externalWorkspaceId: varchar(255), + externalWorkspaceName: PG.nullable(varchar(255)), + status: PG.column(chatSyncConnectionStatusEnum, { default: "active" }), + settings: PG.nullable(jsonb>()), + metadata: PG.nullable(jsonb>()), + errorMessage: PG.nullable(PG.text), + lastSyncedAt: PG.nullable(timestamptz), + createdBy: PG.brand(PG.uuid, UserId), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("chat_sync_conn_org_idx", ["organizationId"]), + PG.index("chat_sync_conn_integration_idx", ["integrationConnectionId"]), + PG.index("chat_sync_conn_provider_idx", ["provider"]), + PG.index("chat_sync_conn_status_idx", ["status"]), + PG.index("chat_sync_conn_deleted_at_idx", ["deletedAt"]), + PG.uniqueIndex( + "chat_sync_conn_org_provider_workspace_unique", + ["organizationId", "provider", "externalWorkspaceId"], + { where: '"deletedAt" IS NULL' }, + ), + PG.uniqueIndex("chat_sync_conn_integration_unique", ["integrationConnectionId"], { + where: '"deletedAt" IS NULL AND "integrationConnectionId" IS NOT NULL', + }), + ], +}) +export type ChatSyncConnection = PG.SelectRowOf +export type NewChatSyncConnection = PG.InsertRowOf + +export const chatSyncEventReceiptsTable = PG.table("chat_sync_event_receipts", { + columns: { + id: PG.column(PG.brand(PG.uuid, SyncEventReceiptId), { defaultExpr: "gen_random_uuid()" }), + syncConnectionId: PG.brand(PG.uuid, SyncConnectionId), + channelLinkId: PG.nullable(PG.brand(PG.uuid, SyncChannelLinkId)), + source: chatSyncReceiptSourceEnum, + externalEventId: PG.nullable(varchar(255)), + dedupeKey: varchar(255), + payloadHash: PG.nullable(varchar(128)), + status: PG.column(chatSyncReceiptStatusEnum, { default: "processed" }), + errorMessage: PG.nullable(PG.text), + processedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + }, + primaryKey: ["id"], + indexes: [ + PG.index("chat_sync_receipt_connection_idx", ["syncConnectionId"]), + PG.index("chat_sync_receipt_channel_link_idx", ["channelLinkId"]), + PG.index("chat_sync_receipt_source_idx", ["source"]), + PG.index("chat_sync_receipt_processed_at_idx", ["processedAt"]), + PG.uniqueIndex("chat_sync_receipt_dedupe_unique", ["syncConnectionId", "source", "dedupeKey"]), + ], +}) +export type ChatSyncEventReceipt = PG.SelectRowOf +export type NewChatSyncEventReceipt = PG.InsertRowOf + +export const chatSyncMessageLinksTable = PG.table("chat_sync_message_links", { + columns: { + id: PG.column(PG.brand(PG.uuid, SyncMessageLinkId), { defaultExpr: "gen_random_uuid()" }), + channelLinkId: PG.brand(PG.uuid, SyncChannelLinkId), + hazelMessageId: PG.brand(PG.uuid, MessageId), + externalMessageId: varchar(255), + source: PG.column(chatSyncReceiptSourceEnum, { default: "hazel" }), + rootHazelMessageId: PG.nullable(PG.brand(PG.uuid, MessageId)), + rootExternalMessageId: PG.nullable(varchar(255)), + hazelThreadChannelId: PG.nullable(PG.brand(PG.uuid, ChannelId)), + externalThreadId: PG.nullable(varchar(255)), + lastSyncedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("chat_sync_msg_link_channel_idx", ["channelLinkId"]), + PG.index("chat_sync_msg_link_hazel_message_idx", ["hazelMessageId"]), + PG.index("chat_sync_msg_link_external_message_idx", ["externalMessageId"]), + PG.index("chat_sync_msg_link_root_hazel_idx", ["rootHazelMessageId"]), + PG.index("chat_sync_msg_link_deleted_at_idx", ["deletedAt"]), + PG.uniqueIndex("chat_sync_msg_link_hazel_unique", ["channelLinkId", "hazelMessageId"], { + where: '"deletedAt" IS NULL', + }), + PG.uniqueIndex("chat_sync_msg_link_external_unique", ["channelLinkId", "externalMessageId"], { + where: '"deletedAt" IS NULL', + }), + ], +}) +export type ChatSyncMessageLink = PG.SelectRowOf +export type NewChatSyncMessageLink = PG.InsertRowOf + +export type ChatSyncOutboundIdentitySettings = { + readonly enabled: boolean + readonly strategy: "webhook" | "fallback_bot" + readonly providers: Record> +} diff --git a/packages/db/src/tables/columns.ts b/packages/db/src/tables/columns.ts new file mode 100644 index 000000000..a58b818f2 --- /dev/null +++ b/packages/db/src/tables/columns.ts @@ -0,0 +1,50 @@ +/** + * Column types Hazel's tables share, beyond the ones `@maple-dev/effect-orm/postgres` ships. + * + * Timestamps read as `Date`, the shape the domain models and every caller already work with; + * comparisons and writes also take a `DateTime.Utc` or an ISO string, as effect-orm's own + * `timestamptz` does. + */ +import * as PG from "@maple-dev/effect-orm/postgres" +import { Schema, SchemaGetter } from "effect" + +/** A timestamptz as a driver sends it (a `Date`, or Postgres's text form), read as a `Date`. */ +const DateFromWire: Schema.Codec = Schema.Union([ + Schema.Date, + Schema.String.pipe( + Schema.decodeTo(Schema.Date, { + decode: SchemaGetter.transform((value: string) => new Date(PG.pgTimestampToIso(value))), + encode: SchemaGetter.transform((value: Date) => value.toISOString()), + }), + ), +]) + +/** `timestamp with time zone`, read and written as a `Date`. */ +export const timestamptz: PG.PgType<"timestamptz", Date, Date | string> = PG.custom( + "timestamptz", + DateFromWire, + PG.PgTimestampLiteral, +) + +/** `varchar(length)`. */ +export const varchar = (length: number): PG.PgType => + PG.custom(`varchar(${length})`, Schema.String) + +/** + * A jsonb document typed as `A` without validating it, as drizzle's `jsonb().$type()` was: + * stored documents predate some of the shapes their readers expect, and a strict decode would + * fail the whole read instead of the one field. + */ +export const jsonb = (): PG.PgType<"jsonb", A, unknown> => PG.jsonb(Schema.Unknown as Schema.Codec) + +/** Narrows a column's TypeScript type without a runtime check (drizzle's `$type()`). */ +export const typed = + () => + >(type: T): PG.PgType> => + type as unknown as PG.PgType> + +/** A Postgres enum type, read as the union of its labels. The `CREATE TYPE` lives in a migration. */ +export const pgEnum = ]>( + name: string, + values: Values, +) => Object.assign(PG.custom(name, Schema.Literals(values)), { enumName: name, enumValues: values }) diff --git a/packages/db/src/tables/connect.ts b/packages/db/src/tables/connect.ts new file mode 100644 index 000000000..173045186 --- /dev/null +++ b/packages/db/src/tables/connect.ts @@ -0,0 +1,142 @@ +import { + ChannelId, + ConnectConversationChannelId, + ConnectConversationId, + ConnectInviteId, + ConnectParticipantId, + OrganizationId, + UserId, +} from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { jsonb, pgEnum, timestamptz, varchar } from "./columns" + +export const connectConversationStatusEnum = pgEnum("connect_conversation_status", ["active", "disconnected"]) +export const connectConversationChannelRoleEnum = pgEnum("connect_conversation_channel_role", [ + "host", + "guest", +]) +export const connectInviteStatusEnum = pgEnum("connect_invite_status", [ + "pending", + "accepted", + "declined", + "revoked", + "expired", +]) +export const connectInviteTargetKindEnum = pgEnum("connect_invite_target_kind", ["slug", "email"]) + +export const connectConversationChannelsTable = PG.table("connect_conversation_channels", { + columns: { + id: PG.column(PG.brand(PG.uuid, ConnectConversationChannelId), { defaultExpr: "gen_random_uuid()" }), + conversationId: PG.brand(PG.uuid, ConnectConversationId), + organizationId: PG.brand(PG.uuid, OrganizationId), + channelId: PG.brand(PG.uuid, ChannelId), + role: connectConversationChannelRoleEnum, + allowGuestMemberAdds: PG.column(PG.bool, { default: false }), + isActive: PG.column(PG.bool, { default: true }), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("connect_conv_channels_conversation_idx", ["conversationId"]), + PG.index("connect_conv_channels_org_idx", ["organizationId"]), + PG.index("connect_conv_channels_channel_idx", ["channelId"]), + PG.index("connect_conv_channels_deleted_at_idx", ["deletedAt"]), + PG.uniqueIndex("connect_conv_channels_channel_unique", ["channelId"], { + where: '"deletedAt" IS NULL', + }), + PG.uniqueIndex( + "connect_conv_channels_conversation_org_unique", + ["conversationId", "organizationId"], + { where: '"deletedAt" IS NULL' }, + ), + ], +}) +export type ConnectConversationChannel = PG.SelectRowOf +export type NewConnectConversationChannel = PG.InsertRowOf + +export const connectConversationsTable = PG.table("connect_conversations", { + columns: { + id: PG.column(PG.brand(PG.uuid, ConnectConversationId), { defaultExpr: "gen_random_uuid()" }), + hostOrganizationId: PG.brand(PG.uuid, OrganizationId), + hostChannelId: PG.brand(PG.uuid, ChannelId), + status: PG.column(connectConversationStatusEnum, { default: "active" }), + settings: PG.nullable(jsonb>()), + createdBy: PG.brand(PG.uuid, UserId), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("connect_conversations_host_org_idx", ["hostOrganizationId"]), + PG.index("connect_conversations_host_channel_idx", ["hostChannelId"]), + PG.index("connect_conversations_status_idx", ["status"]), + PG.index("connect_conversations_deleted_at_idx", ["deletedAt"]), + PG.uniqueIndex("connect_conversations_host_channel_unique", ["hostChannelId"], { + where: '"deletedAt" IS NULL', + }), + ], +}) +export type ConnectConversation = PG.SelectRowOf +export type NewConnectConversation = PG.InsertRowOf + +export const connectInvitesTable = PG.table("connect_invites", { + columns: { + id: PG.column(PG.brand(PG.uuid, ConnectInviteId), { defaultExpr: "gen_random_uuid()" }), + conversationId: PG.brand(PG.uuid, ConnectConversationId), + hostOrganizationId: PG.brand(PG.uuid, OrganizationId), + hostChannelId: PG.brand(PG.uuid, ChannelId), + targetKind: connectInviteTargetKindEnum, + targetValue: varchar(255), + guestOrganizationId: PG.nullable(PG.brand(PG.uuid, OrganizationId)), + status: PG.column(connectInviteStatusEnum, { default: "pending" }), + allowGuestMemberAdds: PG.column(PG.bool, { default: false }), + invitedBy: PG.brand(PG.uuid, UserId), + acceptedBy: PG.nullable(PG.brand(PG.uuid, UserId)), + acceptedAt: PG.nullable(timestamptz), + expiresAt: PG.nullable(timestamptz), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("connect_invites_conversation_idx", ["conversationId"]), + PG.index("connect_invites_host_org_idx", ["hostOrganizationId"]), + PG.index("connect_invites_guest_org_idx", ["guestOrganizationId"]), + PG.index("connect_invites_status_idx", ["status"]), + PG.index("connect_invites_target_idx", ["targetKind", "targetValue"]), + PG.index("connect_invites_deleted_at_idx", ["deletedAt"]), + ], +}) +export type ConnectInvite = PG.SelectRowOf +export type NewConnectInvite = PG.InsertRowOf + +export const connectParticipantsTable = PG.table("connect_participants", { + columns: { + id: PG.column(PG.brand(PG.uuid, ConnectParticipantId), { defaultExpr: "gen_random_uuid()" }), + conversationId: PG.brand(PG.uuid, ConnectConversationId), + channelId: PG.brand(PG.uuid, ChannelId), + userId: PG.brand(PG.uuid, UserId), + homeOrganizationId: PG.brand(PG.uuid, OrganizationId), + isExternal: PG.column(PG.bool, { default: false }), + addedBy: PG.nullable(PG.brand(PG.uuid, UserId)), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("connect_participants_conversation_idx", ["conversationId"]), + PG.index("connect_participants_channel_idx", ["channelId"]), + PG.index("connect_participants_user_idx", ["userId"]), + PG.index("connect_participants_deleted_at_idx", ["deletedAt"]), + PG.uniqueIndex("connect_participants_channel_user_unique", ["channelId", "userId"], { + where: '"deletedAt" IS NULL', + }), + ], +}) +export type ConnectParticipant = PG.SelectRowOf +export type NewConnectParticipant = PG.InsertRowOf diff --git a/packages/db/src/tables/custom-emojis.ts b/packages/db/src/tables/custom-emojis.ts new file mode 100644 index 000000000..0de00a9ce --- /dev/null +++ b/packages/db/src/tables/custom-emojis.ts @@ -0,0 +1,27 @@ +import { CustomEmojiId, OrganizationId, UserId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { timestamptz, varchar } from "./columns" + +export const customEmojisTable = PG.table("custom_emojis", { + columns: { + id: PG.column(PG.brand(PG.uuid, CustomEmojiId), { defaultExpr: "gen_random_uuid()" }), + organizationId: PG.brand(PG.uuid, OrganizationId), + name: varchar(64), + imageUrl: PG.text, + createdBy: PG.brand(PG.uuid, UserId), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.nullable(timestamptz), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("custom_emojis_organization_id_idx", ["organizationId"]), + PG.index("custom_emojis_name_idx", ["name"]), + PG.uniqueIndex("custom_emojis_org_name_unique_idx", ["organizationId", "name"], { + where: '"deletedAt" IS NULL', + }), + PG.index("custom_emojis_deleted_at_idx", ["deletedAt"]), + ], +}) +export type CustomEmoji = PG.SelectRowOf +export type NewCustomEmoji = PG.InsertRowOf diff --git a/packages/db/src/tables/github-subscriptions.ts b/packages/db/src/tables/github-subscriptions.ts new file mode 100644 index 000000000..1c2307538 --- /dev/null +++ b/packages/db/src/tables/github-subscriptions.ts @@ -0,0 +1,37 @@ +import type { GitHubSubscription as GitHubSubscriptionModel } from "@hazel/domain/models" +import { ChannelId, GitHubSubscriptionId, OrganizationId, UserId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { jsonb, timestamptz, varchar } from "./columns" + +type GitHubEventType = GitHubSubscriptionModel.GitHubEventType + +export const githubSubscriptionsTable = PG.table("github_subscriptions", { + columns: { + id: PG.column(PG.brand(PG.uuid, GitHubSubscriptionId), { defaultExpr: "gen_random_uuid()" }), + channelId: PG.brand(PG.uuid, ChannelId), + organizationId: PG.brand(PG.uuid, OrganizationId), + repositoryId: PG.int8, + repositoryFullName: varchar(255), + repositoryOwner: varchar(255), + repositoryName: varchar(255), + enabledEvents: PG.column(jsonb(), { default: ["push", "pull_request", "issues"] }), + branchFilter: PG.nullable(varchar(255)), + isEnabled: PG.column(PG.bool, { default: true }), + createdBy: PG.brand(PG.uuid, UserId), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("github_subscriptions_channel_id_idx", ["channelId"]), + PG.index("github_subscriptions_organization_id_idx", ["organizationId"]), + PG.index("github_subscriptions_repository_id_idx", ["repositoryId"]), + PG.index("github_subscriptions_deleted_at_idx", ["deletedAt"]), + PG.uniqueIndex("github_subscriptions_channel_repo_unique", ["channelId", "repositoryId"], { + where: '"deletedAt" IS NULL', + }), + ], +}) +export type GitHubSubscription = PG.SelectRowOf +export type NewGitHubSubscription = PG.InsertRowOf diff --git a/packages/db/src/tables/index.ts b/packages/db/src/tables/index.ts new file mode 100644 index 000000000..eb0dcad79 --- /dev/null +++ b/packages/db/src/tables/index.ts @@ -0,0 +1,28 @@ +// Every Hazel table as an effect-orm definition: the query builder reads them, and `effect-orm generate` +// diffs them into migrations. + +export * from "./attachments" +export * from "./bot-commands" +export * from "./bot-installations" +export * from "./bots" +export * from "./channel-access" +export * from "./channel-sections" +export * from "./channel-webhooks" +export * from "./channels" +export * from "./chat-sync" +export * from "./connect" +export * from "./custom-emojis" +export * from "./github-subscriptions" +export * from "./integration-connections" +export * from "./integration-requests" +export * from "./integration-tokens" +export * from "./message-outbox" +export * from "./messages" +export * from "./notifications" +export * from "./organizations" +export * from "./pinned-messages" +export * from "./rss-subscriptions" +export * from "./typing-indicators" +export * from "./user-presence-status" +export * from "./users" +export { jsonb, pgEnum, timestamptz, typed, varchar } from "./columns" diff --git a/packages/db/src/tables/integration-connections.ts b/packages/db/src/tables/integration-connections.ts new file mode 100644 index 000000000..7d01cf6a8 --- /dev/null +++ b/packages/db/src/tables/integration-connections.ts @@ -0,0 +1,51 @@ +import { IntegrationConnectionId, OrganizationId, UserId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { jsonb, pgEnum, timestamptz, typed, varchar } from "./columns" + +export const connectionLevelEnum = pgEnum("connection_level", ["organization", "user"]) +export const connectionStatusEnum = pgEnum("connection_status", [ + "active", + "expired", + "revoked", + "error", + "suspended", +]) + +export const integrationConnectionsTable = PG.table("integration_connections", { + columns: { + id: PG.column(PG.brand(PG.uuid, IntegrationConnectionId), { defaultExpr: "gen_random_uuid()" }), + provider: typed<"linear" | "github" | "figma" | "notion" | "discord" | "craft">()(varchar(50)), + organizationId: PG.brand(PG.uuid, OrganizationId), + userId: PG.nullable(PG.brand(PG.uuid, UserId)), + level: connectionLevelEnum, + status: PG.column(connectionStatusEnum, { default: "active" }), + externalAccountId: PG.nullable(varchar(255)), + externalAccountName: PG.nullable(varchar(255)), + connectedBy: PG.brand(PG.uuid, UserId), + settings: PG.nullable(jsonb>()), + metadata: PG.nullable(jsonb>()), + errorMessage: PG.nullable(PG.text), + lastUsedAt: PG.nullable(timestamptz), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("int_conn_org_idx", ["organizationId"]), + PG.index("int_conn_provider_idx", ["provider"]), + PG.index("int_conn_status_idx", ["status"]), + PG.index("int_conn_deleted_at_idx", ["deletedAt"]), + PG.uniqueIndex("int_conn_org_user_provider_unique", ["organizationId", "userId", "provider"]), + PG.uniqueIndex( + "int_conn_org_provider_external_user_unique", + ["organizationId", "provider", "externalAccountId"], + { + where: '"deletedAt" IS NULL\n\t\t\t\t\tAND "level" = \'user\'\n\t\t\t\t\tAND "externalAccountId" IS NOT NULL', + }, + ), + PG.index("int_conn_github_installation_idx", ($) => [PG.jsonText($.metadata, "installationId")]), + ], +}) +export type IntegrationConnection = PG.SelectRowOf +export type NewIntegrationConnection = PG.InsertRowOf diff --git a/packages/db/src/tables/integration-requests.ts b/packages/db/src/tables/integration-requests.ts new file mode 100644 index 000000000..60f8828b0 --- /dev/null +++ b/packages/db/src/tables/integration-requests.ts @@ -0,0 +1,32 @@ +import { IntegrationRequestId, OrganizationId, UserId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { pgEnum, timestamptz, varchar } from "./columns" + +export const integrationRequestStatusEnum = pgEnum("integration_request_status", [ + "pending", + "reviewed", + "planned", + "rejected", +]) + +export const integrationRequestsTable = PG.table("integration_requests", { + columns: { + id: PG.column(PG.brand(PG.uuid, IntegrationRequestId), { defaultExpr: "gen_random_uuid()" }), + organizationId: PG.brand(PG.uuid, OrganizationId), + requestedBy: PG.brand(PG.uuid, UserId), + integrationName: varchar(255), + integrationUrl: PG.nullable(varchar(500)), + description: PG.nullable(PG.text), + status: PG.column(integrationRequestStatusEnum, { default: "pending" }), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + }, + primaryKey: ["id"], + indexes: [ + PG.index("integration_requests_organization_id_idx", ["organizationId"]), + PG.index("integration_requests_status_idx", ["status"]), + PG.index("integration_requests_created_at_idx", ["createdAt"]), + ], +}) +export type IntegrationRequest = PG.SelectRowOf +export type NewIntegrationRequest = PG.InsertRowOf diff --git a/packages/db/src/tables/integration-tokens.ts b/packages/db/src/tables/integration-tokens.ts new file mode 100644 index 000000000..a18636f16 --- /dev/null +++ b/packages/db/src/tables/integration-tokens.ts @@ -0,0 +1,31 @@ +import { IntegrationConnectionId, IntegrationTokenId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { timestamptz, varchar } from "./columns" + +export const integrationTokensTable = PG.table("integration_tokens", { + columns: { + id: PG.column(PG.brand(PG.uuid, IntegrationTokenId), { defaultExpr: "gen_random_uuid()" }), + connectionId: PG.brand(PG.uuid, IntegrationConnectionId), + encryptedAccessToken: PG.text, + encryptedRefreshToken: PG.nullable(PG.text), + iv: varchar(32), + refreshTokenIv: PG.nullable(varchar(32)), + encryptionKeyVersion: PG.column(PG.int4, { default: 1 }), + tokenType: PG.column(PG.nullable(varchar(50)), { default: "Bearer" }), + scope: PG.nullable(PG.text), + expiresAt: PG.nullable(timestamptz), + refreshTokenExpiresAt: PG.nullable(timestamptz), + lastRefreshedAt: PG.nullable(timestamptz), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + }, + primaryKey: ["id"], + indexes: [ + PG.uniqueIndex("integration_tokens_connectionId_unique", ["connectionId"]), + PG.index("int_tokens_connection_id_idx", ["connectionId"]), + PG.index("int_tokens_expires_at_idx", ["expiresAt"]), + PG.index("int_tokens_key_version_idx", ["encryptionKeyVersion"]), + ], +}) +export type IntegrationToken = PG.SelectRowOf +export type NewIntegrationToken = PG.InsertRowOf diff --git a/packages/db/src/tables/message-outbox.ts b/packages/db/src/tables/message-outbox.ts new file mode 100644 index 000000000..807d198d7 --- /dev/null +++ b/packages/db/src/tables/message-outbox.ts @@ -0,0 +1,50 @@ +import { ChannelId, MessageOutboxEventId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { jsonb, pgEnum, timestamptz, varchar } from "./columns" + +export const messageOutboxEventTypeEnum = pgEnum("message_outbox_event_type", [ + "message_created", + "message_updated", + "message_deleted", + "reaction_created", + "reaction_deleted", +]) +export const messageOutboxEventStatusEnum = pgEnum("message_outbox_event_status", [ + "pending", + "processing", + "processed", + "failed", +]) + +export const messageOutboxEventsTable = PG.table("message_outbox_events", { + columns: { + id: PG.column(PG.brand(PG.uuid, MessageOutboxEventId), { defaultExpr: "gen_random_uuid()" }), + sequence: PG.column(PG.int8, { identity: "always" }), + eventType: messageOutboxEventTypeEnum, + aggregateId: PG.uuid, + channelId: PG.brand(PG.uuid, ChannelId), + payload: jsonb>(), + status: PG.column(messageOutboxEventStatusEnum, { default: "pending" }), + attemptCount: PG.column(PG.int4, { default: 0 }), + availableAt: PG.column(timestamptz, { defaultExpr: "now()" }), + lockedAt: PG.nullable(timestamptz), + lockedBy: PG.nullable(varchar(255)), + lastError: PG.nullable(PG.text), + processedAt: PG.nullable(timestamptz), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + }, + primaryKey: ["id"], + indexes: [ + PG.uniqueIndex("message_outbox_events_sequence_unique", ["sequence"]), + PG.index("message_outbox_events_status_available_sequence_idx", [ + "status", + "availableAt", + "sequence", + ]), + PG.index("message_outbox_events_channel_sequence_idx", ["channelId", "sequence"]), + PG.index("message_outbox_events_created_at_idx", ["createdAt"]), + PG.index("message_outbox_events_locked_at_idx", ["lockedAt"], { where: "\"status\" = 'processing'" }), + ], +}) +export type MessageOutboxEvent = PG.SelectRowOf +export type NewMessageOutboxEvent = PG.InsertRowOf diff --git a/packages/db/src/tables/messages.ts b/packages/db/src/tables/messages.ts new file mode 100644 index 000000000..bf7a7cbbe --- /dev/null +++ b/packages/db/src/tables/messages.ts @@ -0,0 +1,115 @@ +import { ChannelId, ConnectConversationId, MessageId, MessageReactionId, UserId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { jsonb, timestamptz, varchar } from "./columns" + +// Embed types for JSONB column +export interface MessageEmbedAuthor { + name: string + url?: string + iconUrl?: string +} + +export interface MessageEmbedFooter { + text: string + iconUrl?: string +} + +export interface MessageEmbedField { + name: string + value: string + inline?: boolean +} + +export interface MessageEmbedBadge { + text: string + color?: number // Hex color as integer +} + +/** + * Cached snapshot of live state for non-realtime clients. + * Synced on finalize when the actor completes. + */ +export interface MessageEmbedLiveStateCached { + status: "idle" | "active" | "completed" | "failed" + data: Record + text?: string + progress?: number + error?: string +} + +/** + * Live state configuration for messages with attached RivetKit actors. + * Used for real-time updates like deployment status or AI streaming. + */ +export interface MessageEmbedLiveState { + // Whether this message has an attached actor + enabled: true + // Cached snapshot for non-realtime clients (synced on finalize) + cached?: MessageEmbedLiveStateCached +} + +export interface MessageEmbed { + title?: string + description?: string + url?: string + color?: number // Hex color as integer + author?: MessageEmbedAuthor + footer?: MessageEmbedFooter + image?: { url: string } + thumbnail?: { url: string } + fields?: MessageEmbedField[] + timestamp?: string // ISO 8601 timestamp + badge?: MessageEmbedBadge // Status badge (e.g., "Deployed", "Failed") + liveState?: MessageEmbedLiveState // Live state for real-time updates +} + +export const messageReactionsTable = PG.table("message_reactions", { + columns: { + id: PG.column(PG.brand(PG.uuid, MessageReactionId), { defaultExpr: "gen_random_uuid()" }), + messageId: PG.brand(PG.uuid, MessageId), + channelId: PG.brand(PG.uuid, ChannelId), + conversationId: PG.nullable(PG.brand(PG.uuid, ConnectConversationId)), + userId: PG.brand(PG.uuid, UserId), + emoji: varchar(50), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + }, + primaryKey: ["id"], + indexes: [ + PG.index("reactions_channel_id_idx", ["channelId"]), + PG.index("reactions_conversation_id_idx", ["conversationId"]), + PG.index("reactions_message_id_idx", ["messageId"]), + PG.index("reactions_user_id_idx", ["userId"]), + PG.index("reactions_message_user_emoji_idx", ["messageId", "userId", "emoji"]), + PG.uniqueIndex("reactions_message_user_emoji_unique", ["messageId", "userId", "emoji"]), + ], +}) +export type MessageReaction = PG.SelectRowOf +export type NewMessageReaction = PG.InsertRowOf + +export const messagesTable = PG.table("messages", { + columns: { + id: PG.column(PG.brand(PG.uuid, MessageId), { defaultExpr: "gen_random_uuid()" }), + channelId: PG.brand(PG.uuid, ChannelId), + conversationId: PG.nullable(PG.brand(PG.uuid, ConnectConversationId)), + authorId: PG.brand(PG.uuid, UserId), + content: PG.text, + embeds: PG.nullable(jsonb()), + replyToMessageId: PG.nullable(PG.brand(PG.uuid, MessageId)), + threadChannelId: PG.nullable(PG.brand(PG.uuid, ChannelId)), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.nullable(timestamptz), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("messages_channel_id_idx", ["channelId"]), + PG.index("messages_conversation_id_idx", ["conversationId"]), + PG.index("messages_author_id_idx", ["authorId"]), + PG.index("messages_reply_to_idx", ["replyToMessageId"]), + PG.index("messages_thread_channel_idx", ["threadChannelId"]), + PG.index("messages_created_at_idx", ["createdAt"]), + PG.index("messages_deleted_at_idx", ["deletedAt"]), + ], +}) +export type Message = PG.SelectRowOf +export type NewMessage = PG.InsertRowOf diff --git a/packages/db/src/tables/notifications.ts b/packages/db/src/tables/notifications.ts new file mode 100644 index 000000000..e538ed935 --- /dev/null +++ b/packages/db/src/tables/notifications.ts @@ -0,0 +1,31 @@ +import { NotificationId, OrganizationMemberId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { timestamptz, varchar } from "./columns" + +export const notificationsTable = PG.table("notifications", { + columns: { + id: PG.column(PG.brand(PG.uuid, NotificationId), { defaultExpr: "gen_random_uuid()" }), + memberId: PG.brand(PG.uuid, OrganizationMemberId), + targetedResourceId: PG.nullable(PG.uuid), + targetedResourceType: PG.nullable(varchar(50)), + resourceId: PG.nullable(PG.uuid), + resourceType: PG.nullable(varchar(50)), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + readAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("notifications_member_id_idx", ["memberId"]), + PG.index("notifications_member_created_id_idx", ["memberId", "createdAt", "id"]), + PG.index("notifications_targeted_resource_idx", ["targetedResourceId", "targetedResourceType"]), + PG.index("notifications_resource_idx", ["resourceId", "resourceType"]), + PG.index("notifications_read_at_idx", ["readAt"]), + PG.uniqueIndex( + "notifications_message_channel_dedupe_idx", + ["memberId", "resourceId", "targetedResourceId"], + { where: "\"resourceType\" = 'message' AND \"targetedResourceType\" = 'channel'" }, + ), + ], +}) +export type Notification = PG.SelectRowOf +export type NewNotification = PG.InsertRowOf diff --git a/packages/db/src/tables/organizations.ts b/packages/db/src/tables/organizations.ts new file mode 100644 index 000000000..140f28e33 --- /dev/null +++ b/packages/db/src/tables/organizations.ts @@ -0,0 +1,64 @@ +import { OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { jsonb, pgEnum, timestamptz, varchar } from "./columns" + +export const organizationRoleEnum = pgEnum("organization_role", ["admin", "member", "owner"]) + +export const organizationsTable = PG.table("organizations", { + columns: { + id: PG.column(PG.brand(PG.uuid, OrganizationId), { defaultExpr: "gen_random_uuid()" }), + name: varchar(255), + slug: PG.nullable(varchar(100)), + logoUrl: PG.nullable(PG.text), + settings: PG.nullable(jsonb()), + isPublic: PG.column(PG.bool, { default: false }), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.uniqueIndex("organizations_slug_unique", ["slug"]), + PG.index("organizations_slug_idx", ["slug"]), + PG.index("organizations_deleted_at_idx", ["deletedAt"]), + PG.uniqueIndex( + "organizations_clerk_org_id_unique", + ($) => [PG.jsonText($.settings, "clerkOrganizationId")], + { where: '"deletedAt" IS NULL AND "settings"->>\'clerkOrganizationId\' IS NOT NULL' }, + ), + ], +}) +export type Organization = PG.SelectRowOf +export type NewOrganization = PG.InsertRowOf + +export const organizationMembersTable = PG.table("organization_members", { + columns: { + id: PG.column(PG.brand(PG.uuid, OrganizationMemberId), { defaultExpr: "gen_random_uuid()" }), + organizationId: PG.brand(PG.uuid, OrganizationId), + userId: PG.brand(PG.uuid, UserId), + role: PG.column(organizationRoleEnum, { default: "member" }), + nickname: PG.nullable(varchar(100)), + joinedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + invitedBy: PG.nullable(PG.brand(PG.uuid, UserId)), + metadata: PG.nullable(jsonb()), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("org_members_organization_id_idx", ["organizationId"]), + PG.index("org_members_user_id_idx", ["userId"]), + PG.uniqueIndex("org_members_org_user_unique", ["organizationId", "userId"]), + ], + foreignKeys: [ + PG.foreignKey({ + name: "organization_members_organizationId_organizations_id_fk", + columns: ["organizationId"], + references: organizationsTable, + foreignColumns: ["id"], + onDelete: "cascade", + }), + ], +}) +export type OrganizationMember = PG.SelectRowOf +export type NewOrganizationMember = PG.InsertRowOf diff --git a/packages/db/src/tables/pinned-messages.ts b/packages/db/src/tables/pinned-messages.ts new file mode 100644 index 000000000..ba36374de --- /dev/null +++ b/packages/db/src/tables/pinned-messages.ts @@ -0,0 +1,21 @@ +import { ChannelId, MessageId, PinnedMessageId, UserId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { timestamptz } from "./columns" + +export const pinnedMessagesTable = PG.table("pinned_messages", { + columns: { + id: PG.column(PG.brand(PG.uuid, PinnedMessageId), { defaultExpr: "gen_random_uuid()" }), + channelId: PG.brand(PG.uuid, ChannelId), + messageId: PG.brand(PG.uuid, MessageId), + pinnedBy: PG.brand(PG.uuid, UserId), + pinnedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + }, + primaryKey: ["id"], + indexes: [ + PG.index("pinned_messages_channel_id_idx", ["channelId"]), + PG.index("pinned_messages_message_id_idx", ["messageId"]), + PG.uniqueIndex("pinned_messages_unique_idx", ["channelId", "messageId"]), + ], +}) +export type PinnedMessage = PG.SelectRowOf +export type NewPinnedMessage = PG.InsertRowOf diff --git a/packages/db/src/tables/rss-subscriptions.ts b/packages/db/src/tables/rss-subscriptions.ts new file mode 100644 index 000000000..417d3b661 --- /dev/null +++ b/packages/db/src/tables/rss-subscriptions.ts @@ -0,0 +1,56 @@ +import { ChannelId, MessageId, OrganizationId, RssSubscriptionId, UserId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { timestamptz, varchar } from "./columns" + +export const rssPostedItemsTable = PG.table("rss_posted_items", { + columns: { + id: PG.column(PG.uuid, { defaultExpr: "gen_random_uuid()" }), + subscriptionId: PG.brand(PG.uuid, RssSubscriptionId), + itemGuid: PG.text, + itemUrl: PG.nullable(PG.text), + messageId: PG.nullable(PG.brand(PG.uuid, MessageId)), + postedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + }, + primaryKey: ["id"], + indexes: [ + PG.index("rss_posted_items_subscription_id_idx", ["subscriptionId"]), + PG.uniqueIndex("rss_posted_items_sub_guid_unique", ["subscriptionId", "itemGuid"]), + ], +}) +export type RssPostedItem = PG.SelectRowOf +export type NewRssPostedItem = PG.InsertRowOf + +export const rssSubscriptionsTable = PG.table("rss_subscriptions", { + columns: { + id: PG.column(PG.brand(PG.uuid, RssSubscriptionId), { defaultExpr: "gen_random_uuid()" }), + channelId: PG.brand(PG.uuid, ChannelId), + organizationId: PG.brand(PG.uuid, OrganizationId), + feedUrl: PG.text, + feedTitle: PG.nullable(varchar(255)), + feedIconUrl: PG.nullable(PG.text), + lastFetchedAt: PG.nullable(timestamptz), + lastItemPublishedAt: PG.nullable(timestamptz), + lastItemGuid: PG.nullable(PG.text), + consecutiveErrors: PG.column(PG.int4, { default: 0 }), + lastErrorMessage: PG.nullable(PG.text), + lastErrorAt: PG.nullable(timestamptz), + isEnabled: PG.column(PG.bool, { default: true }), + pollingIntervalMinutes: PG.column(PG.int4, { default: 15 }), + createdBy: PG.brand(PG.uuid, UserId), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.index("rss_subscriptions_channel_id_idx", ["channelId"]), + PG.index("rss_subscriptions_organization_id_idx", ["organizationId"]), + PG.index("rss_subscriptions_deleted_at_idx", ["deletedAt"]), + PG.index("rss_subscriptions_poll_idx", ["isEnabled", "lastFetchedAt", "deletedAt"]), + PG.uniqueIndex("rss_subscriptions_channel_feed_unique", ["channelId", "feedUrl"], { + where: '"deletedAt" IS NULL', + }), + ], +}) +export type RssSubscription = PG.SelectRowOf +export type NewRssSubscription = PG.InsertRowOf diff --git a/packages/db/src/tables/typing-indicators.ts b/packages/db/src/tables/typing-indicators.ts new file mode 100644 index 000000000..ab7b783ee --- /dev/null +++ b/packages/db/src/tables/typing-indicators.ts @@ -0,0 +1,20 @@ +import { ChannelId, ChannelMemberId, TypingIndicatorId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import {} from "./columns" + +export const typingIndicatorsTable = PG.table("typing_indicators", { + columns: { + id: PG.column(PG.brand(PG.uuid, TypingIndicatorId), { defaultExpr: "gen_random_uuid()" }), + channelId: PG.brand(PG.uuid, ChannelId), + memberId: PG.brand(PG.uuid, ChannelMemberId), + lastTyped: PG.int8, + }, + primaryKey: ["id"], + indexes: [ + PG.uniqueIndex("typing_indicators_channel_member_unique", ["channelId", "memberId"]), + PG.index("typing_indicators_channel_timestamp_idx", ["channelId", "lastTyped"]), + PG.index("typing_indicators_timestamp_idx", ["lastTyped"]), + ], +}) +export type TypingIndicator = PG.SelectRowOf +export type NewTypingIndicator = PG.InsertRowOf diff --git a/packages/db/src/tables/user-presence-status.ts b/packages/db/src/tables/user-presence-status.ts new file mode 100644 index 000000000..4512d6511 --- /dev/null +++ b/packages/db/src/tables/user-presence-status.ts @@ -0,0 +1,35 @@ +import { ChannelId, UserId, UserPresenceStatusId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { pgEnum, timestamptz, varchar } from "./columns" + +export const userPresenceStatusEnum = pgEnum("user_presence_status_enum", [ + "online", + "away", + "busy", + "dnd", + "offline", +]) + +export const userPresenceStatusTable = PG.table("user_presence_status", { + columns: { + id: PG.column(PG.brand(PG.uuid, UserPresenceStatusId), { defaultExpr: "gen_random_uuid()" }), + userId: PG.brand(PG.uuid, UserId), + status: PG.column(userPresenceStatusEnum, { default: "online" }), + customMessage: PG.nullable(varchar(255)), + statusEmoji: PG.nullable(varchar(10)), + statusExpiresAt: PG.nullable(timestamptz), + activeChannelId: PG.nullable(PG.brand(PG.uuid, ChannelId)), + suppressNotifications: PG.column(PG.bool, { default: false }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + lastSeenAt: PG.column(timestamptz, { defaultExpr: "now()" }), + }, + primaryKey: ["id"], + indexes: [ + PG.uniqueIndex("user_presence_status_userId_unique", ["userId"]), + PG.index("user_presence_status_user_id_idx", ["userId"]), + PG.index("user_presence_status_active_channel_idx", ["activeChannelId"]), + PG.index("user_presence_status_last_seen_idx", ["lastSeenAt"]), + ], +}) +export type UserPresenceStatus = PG.SelectRowOf +export type NewUserPresenceStatus = PG.InsertRowOf diff --git a/packages/db/src/tables/users.ts b/packages/db/src/tables/users.ts new file mode 100644 index 000000000..24c3f5ccb --- /dev/null +++ b/packages/db/src/tables/users.ts @@ -0,0 +1,33 @@ +import { UserId } from "@hazel/schema" +import * as PG from "@maple-dev/effect-orm/postgres" +import { jsonb, pgEnum, timestamptz, varchar } from "./columns" + +export const userTypeEnum = pgEnum("user_type", ["user", "machine"]) + +export const usersTable = PG.table("users", { + columns: { + id: PG.column(PG.brand(PG.uuid, UserId), { defaultExpr: "gen_random_uuid()" }), + externalId: varchar(255), + email: varchar(255), + firstName: varchar(100), + lastName: varchar(100), + avatarUrl: PG.nullable(PG.text), + userType: PG.column(userTypeEnum, { default: "user" }), + settings: PG.nullable(jsonb>()), + isOnboarded: PG.column(PG.bool, { default: false }), + timezone: PG.nullable(varchar(100)), + createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), + updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), + deletedAt: PG.nullable(timestamptz), + }, + primaryKey: ["id"], + indexes: [ + PG.uniqueIndex("users_externalId_unique", ["externalId"]), + PG.index("users_external_id_idx", ["externalId"]), + PG.index("users_email_idx", ["email"]), + PG.index("users_user_type_idx", ["userType"]), + PG.index("users_deleted_at_idx", ["deletedAt"]), + ], +}) +export type User = PG.SelectRowOf +export type NewUser = PG.InsertRowOf From f75462e6ef4e42ea59679c4fcc309cdec9301344 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 13 Oct 2026 00:29:53 +0200 Subject: [PATCH 2/8] refactor(db): Database service, repositories and migrations on effect-orm Database.Database is effect-orm over @effect/sql-pg: run/first/query/ execute/transaction, a pooled client for long-running processes and a per-request client for Workers. Transactions pin every statement in their body through fiber context, so the TxFn/TransactionContext threading and makeQuery wrappers are gone. DatabaseError keeps its type classification and gains constraint and sqlState. Every backend-core repository and the proxy auth consumer query through the PG builder over @hazel/db/tables. The Electric where-clause builder renders its own parameterized fragments with unqualified column names. Schema management moves from drizzle-kit push to effect-orm migrations: an initial migration with the schema push left, then an effect-orm baseline. db:migrate adopts a database push built by recording both as applied, and migrates a fresh one from scratch. Callers in apps/* are converted in the next commit. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 23 +- README.md | 4 +- apps/backend/src/lib/create-transactionId.ts | 56 +- .../src/lib/transaction-aware-execute.ts | 15 - apps/backend/src/worker/platform.ts | 10 +- apps/bot-gateway/src/gateway/auth.ts | 5 +- apps/electric-proxy/src/tables/bot-tables.ts | 9 +- apps/electric-proxy/src/tables/user-tables.ts | 122 +- .../src/tables/where-clause-builder.test.ts | 16 +- .../src/tables/where-clause-builder.ts | 213 +- apps/electric-proxy/src/worker.ts | 24 +- bots/linear-bot/package.json | 1 - bun.lock | 10 +- infra/README.md | 2 +- libs/bot-sdk/tsdown.config.ts | 1 - packages/auth/src/consumers/proxy-auth.ts | 36 +- packages/backend-core/package.json | 1 - .../src/repositories/bot-command-repo.ts | 194 +- .../src/repositories/bot-installation-repo.ts | 68 +- .../backend-core/src/repositories/bot-repo.ts | 300 +- .../src/repositories/channel-member-repo.ts | 132 +- .../src/repositories/channel-repo.ts | 33 +- .../src/repositories/channel-webhook-repo.ts | 133 +- .../chat-sync-channel-link-repo.ts | 323 +- .../repositories/chat-sync-connection-repo.ts | 202 +- .../chat-sync-event-receipt-repo.ts | 268 +- .../chat-sync-message-link-repo.ts | 203 +- .../connect-conversation-channel-repo.ts | 86 +- .../repositories/connect-conversation-repo.ts | 28 +- .../src/repositories/connect-invite-repo.ts | 96 +- .../repositories/connect-participant-repo.ts | 94 +- .../src/repositories/custom-emoji-repo.ts | 110 +- .../repositories/github-subscription-repo.ts | 139 +- .../integration-connection-repo.ts | 423 +- .../repositories/integration-token-repo.ts | 84 +- .../src/repositories/message-outbox-repo.ts | 265 +- .../src/repositories/message-reaction-repo.ts | 46 +- .../src/repositories/message-repo.ts | 279 +- .../src/repositories/notification-repo.ts | 57 +- .../repositories/organization-member-repo.ts | 204 +- .../src/repositories/organization-repo.ts | 172 +- .../src/repositories/rss-subscription-repo.ts | 128 +- .../src/repositories/typing-indicator-repo.ts | 101 +- .../repositories/user-presence-status-repo.ts | 191 +- .../src/repositories/user-repo.ts | 122 +- packages/db/drizzle.config.ts | 16 - packages/db/effect-orm.config.ts | 23 + .../migration.sql | 856 +++ .../migration.sql | 1 + .../snapshot.json | 5340 +++++++++++++++++ packages/db/package.json | 25 +- packages/db/scripts/migrate.ts | 32 + packages/db/scripts/render-ddl.ts | 20 +- packages/db/src/index.ts | 7 +- packages/db/src/migrate.ts | 81 + packages/db/src/schema/attachments.ts | 36 - packages/db/src/schema/bot-commands.ts | 49 - packages/db/src/schema/bot-installations.ts | 25 - packages/db/src/schema/bots.ts | 39 - packages/db/src/schema/channel-access.ts | 27 - packages/db/src/schema/channel-sections.ts | 25 - packages/db/src/schema/channel-webhooks.ts | 40 - packages/db/src/schema/channels.ts | 79 - packages/db/src/schema/chat-sync.ts | 188 - packages/db/src/schema/connect.ts | 154 - packages/db/src/schema/custom-emojis.ts | 30 - .../db/src/schema/github-subscriptions.ts | 53 - packages/db/src/schema/index.ts | 29 - .../db/src/schema/integration-connections.ts | 83 - .../db/src/schema/integration-requests.ts | 35 - packages/db/src/schema/integration-tokens.ts | 37 - .../src/schema/message-integration-links.ts | 39 - packages/db/src/schema/message-outbox.ts | 66 - packages/db/src/schema/messages.ts | 123 - packages/db/src/schema/notifications.ts | 33 - packages/db/src/schema/organizations.ts | 75 - packages/db/src/schema/pinned-messages.ts | 24 - packages/db/src/schema/relations.ts | 408 -- packages/db/src/schema/rss-subscriptions.ts | 69 - packages/db/src/schema/typing-indicators.ts | 22 - .../db/src/schema/user-presence-status.ts | 37 - packages/db/src/schema/users.ts | 33 - packages/db/src/services/database.test.ts | 273 +- packages/db/src/services/database.ts | 502 +- packages/db/src/services/drizzle-effect.ts | 284 - packages/db/src/services/index.ts | 3 +- packages/db/src/services/repository.ts | 183 +- packages/db/src/tables/columns.ts | 16 +- packages/db/src/tables/index.ts | 2 +- .../db/src/tables/integration-connections.ts | 2 +- packages/db/src/tables/organizations.ts | 2 +- packages/domain/package.json | 1 - packages/infra/src/cloudflare/hazel-db.ts | 4 +- packages/setup/src/commands/setup.ts | 8 +- turbo.json | 2 +- 95 files changed, 8752 insertions(+), 5818 deletions(-) delete mode 100644 apps/backend/src/lib/transaction-aware-execute.ts delete mode 100644 packages/db/drizzle.config.ts create mode 100644 packages/db/effect-orm.config.ts create mode 100644 packages/db/migrations/20261012000000_initial_schema/migration.sql create mode 100644 packages/db/migrations/20261012222436_effect_orm_baseline/migration.sql create mode 100644 packages/db/migrations/20261012222436_effect_orm_baseline/snapshot.json create mode 100644 packages/db/scripts/migrate.ts create mode 100644 packages/db/src/migrate.ts delete mode 100644 packages/db/src/schema/attachments.ts delete mode 100644 packages/db/src/schema/bot-commands.ts delete mode 100644 packages/db/src/schema/bot-installations.ts delete mode 100644 packages/db/src/schema/bots.ts delete mode 100644 packages/db/src/schema/channel-access.ts delete mode 100644 packages/db/src/schema/channel-sections.ts delete mode 100644 packages/db/src/schema/channel-webhooks.ts delete mode 100644 packages/db/src/schema/channels.ts delete mode 100644 packages/db/src/schema/chat-sync.ts delete mode 100644 packages/db/src/schema/connect.ts delete mode 100644 packages/db/src/schema/custom-emojis.ts delete mode 100644 packages/db/src/schema/github-subscriptions.ts delete mode 100644 packages/db/src/schema/index.ts delete mode 100644 packages/db/src/schema/integration-connections.ts delete mode 100644 packages/db/src/schema/integration-requests.ts delete mode 100644 packages/db/src/schema/integration-tokens.ts delete mode 100644 packages/db/src/schema/message-integration-links.ts delete mode 100644 packages/db/src/schema/message-outbox.ts delete mode 100644 packages/db/src/schema/messages.ts delete mode 100644 packages/db/src/schema/notifications.ts delete mode 100644 packages/db/src/schema/organizations.ts delete mode 100644 packages/db/src/schema/pinned-messages.ts delete mode 100644 packages/db/src/schema/relations.ts delete mode 100644 packages/db/src/schema/rss-subscriptions.ts delete mode 100644 packages/db/src/schema/typing-indicators.ts delete mode 100644 packages/db/src/schema/user-presence-status.ts delete mode 100644 packages/db/src/schema/users.ts delete mode 100644 packages/db/src/services/drizzle-effect.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4171a596b..d30a6a39a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ This is a monorepo with the following structure: - `apps/web/` - React frontend using Vite, TanStack Router, and TailwindCSS - `apps/backendv2/` - Backend API using Bun runtime and Effect-TS - `apps/cluster/` - Effect Cluster service for distributed workflows and background jobs -- `packages/db/` - Shared database package using Drizzle ORM and PostgreSQL +- `packages/db/` - Shared database package: effect-orm table definitions, the `Database` service, and migrations - `packages/domain/` - Shared domain types, RPC contracts, and cluster definitions - `.context/` - Git Subtrees for context of how to use specific libraries (In this case Effect and Effect Atom) @@ -92,7 +92,9 @@ When working with Effect, Effect Atom, or TanStack DB, refer to these directorie ### Database (packages/db) -- `bun run db` - Run Drizzle Kit commands for schema management +- `bun run db:migrate` - Apply migrations to `DATABASE_URL` (adopts a database `drizzle-kit push` built) +- `bun run db:generate --name ` - Diff `src/tables` against the migrations and write the next one +- `bun run db:check` / `db:status` / `db:verify` - Validate the folder, list applied migrations, compare with a live schema ## Tech Stack @@ -112,7 +114,7 @@ When working with Effect, Effect Atom, or TanStack DB, refer to these directorie - **Runtime**: Bun - **Framework**: Effect-TS for functional programming -- **Database**: PostgreSQL with Drizzle ORM +- **Database**: PostgreSQL with effect-orm (`@maple-dev/effect-orm`) over `@effect/sql-pg` - **Auth**: Clerk (`@clerk/backend` JWT verification + webhook sync) - **API**: RPC-style endpoints via Effect Http Api @@ -182,7 +184,20 @@ Available branded types in `@hazel/schema`: `OrganizationId`, `ChannelId`, `User ## Database -Uses Drizzle ORM with PostgreSQL. Database schema is defined in `packages/db/src/schema/`. Use `bun run db` commands for migrations and schema management. +Uses effect-orm (`@maple-dev/effect-orm`, source in `~/Documents/GitHub/effect-clickhouse`) over `@effect/sql-pg`. Tables are `PG.table` definitions in `packages/db/src/tables/`; migrations live in `packages/db/migrations/`. + +```typescript +import { Database, PG, schema } from "@hazel/db" + +const db = yield* Database.Database +const channel = yield* db.first( + PG.from(schema.channelsTable).select().where(($) => [$.id.eq(channelId), $.deletedAt.isNull()]), +) +yield* db.transaction(Effect.gen(function* () { /* every statement in here joins the transaction */ })) +``` + +- `db.transaction(effect)` pins every statement inside `effect` (repositories included) to one connection through fiber context; there is no transaction handle to pass around. +- Schema changes: edit `src/tables`, then `bun run db:generate --name `. effect-orm does not model enums or unique constraints: write those in a `--custom` migration. ## Architecture Notes diff --git a/README.md b/README.md index 6e9bd96c8..4ca8e7fc7 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Hazel Chat is a full-stack chat application featuring: - **Bun** runtime - **Effect-TS** functional programming framework - **Effect RPC** for type-safe APIs -- **Drizzle ORM** with PostgreSQL +- **effect-orm** (`@maple-dev/effect-orm`) with PostgreSQL - **Clerk** for authentication ### Cluster Service @@ -65,7 +65,7 @@ Hazel Chat is a full-stack chat application featuring: │ ├── electric-proxy/ # Electric SQL proxy worker │ └── link-preview-worker/ # Link preview generation ├── packages/ -│ ├── db/ # Drizzle ORM schemas and migration artifacts (`drizzle/`) +│ ├── db/ # effect-orm table definitions and migrations (`migrations/`) │ ├── domain/ # Shared types, RPC contracts, cluster definitions │ ├── backend-core/ # Reusable backend services and repositories │ ├── schema/ # Shared schema definitions diff --git a/apps/backend/src/lib/create-transactionId.ts b/apps/backend/src/lib/create-transactionId.ts index b5af8b6d7..552587143 100644 --- a/apps/backend/src/lib/create-transactionId.ts +++ b/apps/backend/src/lib/create-transactionId.ts @@ -1,45 +1,23 @@ import { Database } from "@hazel/db" import { TransactionIdFromString } from "@hazel/schema" -import { Effect, Option, Predicate, Schema } from "effect" +import { Effect, Schema } from "effect" -export const generateTransactionId = Effect.fn("generateTransactionId")(function* ( - tx?: ( - fn: (client: Database.TransactionClient) => Promise, - ) => Effect.Effect, -) { - // 1. Check explicit tx parameter (highest priority) - let txExecutor: Database.TxFn | undefined = tx +const TxidRow = Schema.Struct({ txid: Schema.String }) - // 2. Check TransactionContext from Effect Context (auto-propagated) - if (!txExecutor) { - const maybeCtx = yield* Effect.serviceOption(Database.TransactionContext) - if (Option.isSome(maybeCtx)) { - txExecutor = maybeCtx.value.execute - } - } - - // 3. Fail if no transaction context found - if (!txExecutor) { - return yield* Effect.die("generateTransactionId must be called within a transaction") - } - - const result = yield* txExecutor((client) => - client.execute(`SELECT pg_current_xact_id()::xid::text as txid`), - ).pipe( - Effect.map((rows) => rows[0]?.txid as string), - Effect.tap((rawTxid) => - Effect.log(`[txid-debug] Raw PostgreSQL txid string: "${rawTxid}", type: ${typeof rawTxid}`), - ), - Effect.flatMap((txid) => Schema.decodeEffect(TransactionIdFromString)(txid)), - Effect.tap((decodedTxid) => - Effect.log(`[txid-debug] Decoded transactionId: ${decodedTxid}, type: ${typeof decodedTxid}`), - ), - Effect.catchIf( - (e): e is Database.DatabaseError => Predicate.isTagged(e, "DatabaseError"), - (err) => Effect.die(`Database error generating transaction ID: ${err}`), - ), +/** + * The id of the open transaction, for Electric to confirm the client's optimistic write against. + * Must run inside `db.transaction`, which pins this statement to the transaction's connection. + */ +export const generateTransactionId = Effect.fn("generateTransactionId")(function* () { + const db = yield* Database.Database + const rows = yield* db + .query(Database.sql`SELECT pg_current_xact_id()::xid::text AS txid`, TxidRow) + .pipe( + Effect.catchTag("DatabaseError", (err) => + Effect.die(`Database error generating transaction ID: ${err}`), + ), + ) + return yield* Schema.decodeEffect(TransactionIdFromString)(rows[0]?.txid ?? "").pipe( Effect.catchTag("SchemaError", (err) => Effect.die(`Failed to parse transaction ID: ${err}`)), ) - - return result -}) +}, Database.requireTransaction) diff --git a/apps/backend/src/lib/transaction-aware-execute.ts b/apps/backend/src/lib/transaction-aware-execute.ts deleted file mode 100644 index 645ff1c42..000000000 --- a/apps/backend/src/lib/transaction-aware-execute.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Database } from "@hazel/db" -import { Effect, Option } from "effect" - -export const transactionAwareExecute = Effect.fn("transactionAwareExecute")( - (fn: (client: Database.TransactionClient) => Promise) => - Effect.gen(function* () { - const maybeTx = yield* Effect.serviceOption(Database.TransactionContext) - if (Option.isSome(maybeTx)) { - return yield* maybeTx.value.execute(fn) - } - - const db = yield* Database.Database - return yield* db.execute((client) => fn(client as unknown as Database.TransactionClient)) - }), -) diff --git a/apps/backend/src/worker/platform.ts b/apps/backend/src/worker/platform.ts index a44347239..a1dd42d7a 100644 --- a/apps/backend/src/worker/platform.ts +++ b/apps/backend/src/worker/platform.ts @@ -58,12 +58,12 @@ export const objectPlatformLive = (env: Record, botGateways: Bo workerEnvLayer(env), ) -/** Opens the request's Postgres client; it connects lazily and is closed with the request scope. */ +/** + * Opens the request's Postgres client; it connects lazily and is closed with the request scope. + * Opening dials nothing, so it cannot fail for want of a database: a failure here is a defect. + */ export const provideRequestDatabase = (env: Record) => Effect.provideServiceEffect( Database.DatabaseConnection, - Effect.acquireRelease( - Effect.sync(() => Database.makeRequestConnection(hazelDbConnectionString(env))), - (connection) => Effect.promise(() => connection.end()).pipe(Effect.ignore), - ), + Database.makeRequestConnection(hazelDbConnectionString(env)).pipe(Effect.orDie), ) diff --git a/apps/bot-gateway/src/gateway/auth.ts b/apps/bot-gateway/src/gateway/auth.ts index 274071f69..a72dcb9d3 100644 --- a/apps/bot-gateway/src/gateway/auth.ts +++ b/apps/bot-gateway/src/gateway/auth.ts @@ -31,10 +31,7 @@ export const authenticateBotToken = (token: string, connectionString: string): E Effect.provide( Layer.effect( Database.DatabaseConnection, - Effect.acquireRelease( - Effect.sync(() => Database.makeRequestConnection(connectionString)), - (connection) => Effect.promise(() => connection.end()), - ), + Database.makeRequestConnection(connectionString).pipe(Effect.orDie), ), ), Effect.withSpan("BotGateway.authenticate"), diff --git a/apps/electric-proxy/src/tables/bot-tables.ts b/apps/electric-proxy/src/tables/bot-tables.ts index 0e203c0f7..18c955328 100644 --- a/apps/electric-proxy/src/tables/bot-tables.ts +++ b/apps/electric-proxy/src/tables/bot-tables.ts @@ -1,4 +1,3 @@ -import { schema } from "@hazel/db" import { Effect, Match, Schema } from "effect" import type { AuthenticatedBot } from "../auth/bot-auth" import type { WhereClauseResult } from "./where-clause-builder" @@ -59,7 +58,7 @@ export function validateBotTable(table: string | null): { * Get the WHERE clause for a table based on the authenticated bot. * This ensures bots can only access data from channels they're installed in. * - * Uses unqualified column names (via column.name) for Electric SQL compatibility. + * Uses unqualified column names for Electric SQL compatibility. * All WHERE clauses are parameterized for security. * * @param table - The table name @@ -77,7 +76,7 @@ export function getBotWhereClauseForTable( Match.when("messages", () => // Messages from all non-deleted channels in orgs where the bot is installed. Effect.succeed({ - whereClause: `"${schema.messagesTable.deletedAt.name}" IS NULL AND "${schema.messagesTable.channelId.name}" IN ${botVisibleChannelSubquery}`, + whereClause: `"deletedAt" IS NULL AND "channelId" IN ${botVisibleChannelSubquery}`, params: [bot.botId], }), ), @@ -85,7 +84,7 @@ export function getBotWhereClauseForTable( Match.when("channels", () => // Channels from orgs where the bot is installed. Effect.succeed({ - whereClause: `"${schema.channelsTable.deletedAt.name}" IS NULL AND "${schema.channelsTable.organizationId.name}" IN ${botOrgSubquery}`, + whereClause: `"deletedAt" IS NULL AND "organizationId" IN ${botOrgSubquery}`, params: [bot.botId], }), ), @@ -93,7 +92,7 @@ export function getBotWhereClauseForTable( Match.when("channel_members", () => // Channel members in channels from orgs where the bot is installed. Effect.succeed({ - whereClause: `"${schema.channelMembersTable.deletedAt.name}" IS NULL AND "${schema.channelMembersTable.channelId.name}" IN ${botVisibleChannelSubquery}`, + whereClause: `"deletedAt" IS NULL AND "channelId" IN ${botVisibleChannelSubquery}`, params: [bot.botId], }), ), diff --git a/apps/electric-proxy/src/tables/user-tables.ts b/apps/electric-proxy/src/tables/user-tables.ts index 41d64bb16..b96a8f1fb 100644 --- a/apps/electric-proxy/src/tables/user-tables.ts +++ b/apps/electric-proxy/src/tables/user-tables.ts @@ -1,4 +1,4 @@ -import { schema, sql } from "@hazel/db" +import { schema } from "@hazel/db" import { Effect, Match, Schema } from "effect" import type { AuthenticatedUser } from "../auth/user-auth" import { @@ -13,10 +13,39 @@ import { eqCol, inSubquery, isNullCol, + refs, + sql, sqlToWhereClause, type WhereClauseResult, } from "./where-clause-builder" +/** The tables whose columns the WHERE clauses name. */ +const T = { + attachments: refs(schema.attachmentsTable), + botInstallations: refs(schema.botInstallationsTable), + bots: refs(schema.botsTable), + channelAccess: refs(schema.channelAccessTable), + channelMembers: refs(schema.channelMembersTable), + channelSections: refs(schema.channelSectionsTable), + channels: refs(schema.channelsTable), + chatSyncChannelLinks: refs(schema.chatSyncChannelLinksTable), + chatSyncConnections: refs(schema.chatSyncConnectionsTable), + chatSyncMessageLinks: refs(schema.chatSyncMessageLinksTable), + connectConversationChannels: refs(schema.connectConversationChannelsTable), + connectConversations: refs(schema.connectConversationsTable), + connectParticipants: refs(schema.connectParticipantsTable), + customEmojis: refs(schema.customEmojisTable), + integrationConnections: refs(schema.integrationConnectionsTable), + messageReactions: refs(schema.messageReactionsTable), + messages: refs(schema.messagesTable), + notifications: refs(schema.notificationsTable), + organizationMembers: refs(schema.organizationMembersTable), + organizations: refs(schema.organizationsTable), + pinnedMessages: refs(schema.pinnedMessagesTable), + typingIndicators: refs(schema.typingIndicatorsTable), + users: refs(schema.usersTable), +} + /** * Error thrown when table access is denied or where clause cannot be generated */ @@ -131,8 +160,8 @@ export function getWhereClauseForTable( table: AllowedTable, user: AuthenticatedUser, ): Effect.Effect { - const channelAccessSubquery = sql`(SELECT ${col(schema.channelAccessTable.channelId)} FROM ${schema.channelAccessTable} WHERE ${eqCol(schema.channelAccessTable.userId, user.internalUserId)})` - const connectConversationAccessSubquery = sql`(SELECT ${col(schema.connectConversationChannelsTable.conversationId)} FROM ${schema.connectConversationChannelsTable} WHERE ${isNullCol(schema.connectConversationChannelsTable.deletedAt)} AND ${col(schema.connectConversationChannelsTable.channelId)} IN ${channelAccessSubquery})` + const channelAccessSubquery = sql`(SELECT ${col(T.channelAccess.channelId)} FROM ${T.channelAccess.$table} WHERE ${eqCol(T.channelAccess.userId, user.internalUserId)})` + const connectConversationAccessSubquery = sql`(SELECT ${col(T.connectConversationChannels.conversationId)} FROM ${T.connectConversationChannels.$table} WHERE ${isNullCol(T.connectConversationChannels.deletedAt)} AND ${col(T.connectConversationChannels.channelId)} IN ${channelAccessSubquery})` // Chat Sync tables — handled before Match.pipe to stay within its 20-arg type limit switch (table) { @@ -140,34 +169,32 @@ export function getWhereClauseForTable( return Effect.succeed( buildOrgMembershipClause( user.internalUserId, - schema.chatSyncConnectionsTable.organizationId, - schema.chatSyncConnectionsTable.deletedAt, + T.chatSyncConnections.organizationId, + T.chatSyncConnections.deletedAt, ), ) case "chat_sync_channel_links": return Effect.succeed( buildChannelAccessClause( user.internalUserId, - schema.chatSyncChannelLinksTable.hazelChannelId, - schema.chatSyncChannelLinksTable.deletedAt, + T.chatSyncChannelLinks.hazelChannelId, + T.chatSyncChannelLinks.deletedAt, ), ) case "chat_sync_message_links": return Effect.succeed( sqlToWhereClause( - schema.chatSyncMessageLinksTable, - sql`${isNullCol(schema.chatSyncMessageLinksTable.deletedAt)} AND ${inSubquery( - schema.chatSyncMessageLinksTable.channelLinkId, - sql`(SELECT ${col(schema.chatSyncChannelLinksTable.id)} FROM ${schema.chatSyncChannelLinksTable} WHERE ${isNullCol(schema.chatSyncChannelLinksTable.deletedAt)} AND ${col(schema.chatSyncChannelLinksTable.hazelChannelId)} IN ${channelAccessSubquery})`, + sql`${isNullCol(T.chatSyncMessageLinks.deletedAt)} AND ${inSubquery( + T.chatSyncMessageLinks.channelLinkId, + sql`(SELECT ${col(T.chatSyncChannelLinks.id)} FROM ${T.chatSyncChannelLinks.$table} WHERE ${isNullCol(T.chatSyncChannelLinks.deletedAt)} AND ${col(T.chatSyncChannelLinks.hazelChannelId)} IN ${channelAccessSubquery})`, )}`, ), ) case "connect_conversations": return Effect.succeed( sqlToWhereClause( - schema.connectConversationsTable, - sql`${isNullCol(schema.connectConversationsTable.deletedAt)} AND ${inSubquery( - schema.connectConversationsTable.id, + sql`${isNullCol(T.connectConversations.deletedAt)} AND ${inSubquery( + T.connectConversations.id, connectConversationAccessSubquery, )}`, ), @@ -176,16 +203,16 @@ export function getWhereClauseForTable( return Effect.succeed( buildChannelAccessClause( user.internalUserId, - schema.connectConversationChannelsTable.channelId, - schema.connectConversationChannelsTable.deletedAt, + T.connectConversationChannels.channelId, + T.connectConversationChannels.deletedAt, ), ) case "connect_participants": return Effect.succeed( buildChannelAccessClause( user.internalUserId, - schema.connectParticipantsTable.channelId, - schema.connectParticipantsTable.deletedAt, + T.connectParticipants.channelId, + T.connectParticipants.deletedAt, ), ) } @@ -198,7 +225,7 @@ export function getWhereClauseForTable( Match.when("users", () => // All non-deleted users (organization filtering removed to support bot machine users // whose organization membership is soft-deleted on bot uninstall) - Effect.succeed(buildDeletedAtNullClause(schema.usersTable.deletedAt)), + Effect.succeed(buildDeletedAtNullClause(T.users.deletedAt)), ), Match.when("user_presence_status", () => @@ -213,11 +240,7 @@ export function getWhereClauseForTable( Match.when("organizations", () => // Organizations: only those the user is a member of Effect.succeed( - buildOrgMembershipClause( - user.internalUserId, - schema.organizationsTable.id, - schema.organizationsTable.deletedAt, - ), + buildOrgMembershipClause(user.internalUserId, T.organizations.id, T.organizations.deletedAt), ), ), @@ -226,8 +249,8 @@ export function getWhereClauseForTable( Effect.succeed( buildOrgMembershipClause( user.internalUserId, - schema.organizationMembersTable.organizationId, - schema.organizationMembersTable.deletedAt, + T.organizationMembers.organizationId, + T.organizationMembers.deletedAt, ), ), ), @@ -238,7 +261,7 @@ export function getWhereClauseForTable( Match.when("channels", () => // Channel visibility: rows materialized in channel_access for this user - Effect.succeed(buildChannelVisibilityClause(user.internalUserId, schema.channelsTable.deletedAt)), + Effect.succeed(buildChannelVisibilityClause(user.internalUserId, T.channels.deletedAt)), ), Match.when("channel_members", () => @@ -246,8 +269,8 @@ export function getWhereClauseForTable( Effect.succeed( buildChannelAccessClause( user.internalUserId, - schema.channelMembersTable.channelId, - schema.channelMembersTable.deletedAt, + T.channelMembers.channelId, + T.channelMembers.deletedAt, ), ), ), @@ -257,8 +280,8 @@ export function getWhereClauseForTable( Effect.succeed( buildOrgMembershipClause( user.internalUserId, - schema.channelSectionsTable.organizationId, - schema.channelSectionsTable.deletedAt, + T.channelSections.organizationId, + T.channelSections.deletedAt, ), ), ), @@ -270,8 +293,7 @@ export function getWhereClauseForTable( Match.when("messages", () => Effect.succeed( sqlToWhereClause( - schema.messagesTable, - sql`${isNullCol(schema.messagesTable.deletedAt)} AND ((${col(schema.messagesTable.conversationId)} IS NULL AND ${inSubquery(schema.messagesTable.channelId, channelAccessSubquery)}) OR (${col(schema.messagesTable.conversationId)} IS NOT NULL AND ${inSubquery(schema.messagesTable.conversationId, connectConversationAccessSubquery)}))`, + sql`${isNullCol(T.messages.deletedAt)} AND ((${col(T.messages.conversationId)} IS NULL AND ${inSubquery(T.messages.channelId, channelAccessSubquery)}) OR (${col(T.messages.conversationId)} IS NOT NULL AND ${inSubquery(T.messages.conversationId, connectConversationAccessSubquery)}))`, ), ), ), @@ -279,8 +301,7 @@ export function getWhereClauseForTable( Match.when("message_reactions", () => Effect.succeed( sqlToWhereClause( - schema.messageReactionsTable, - sql`((${col(schema.messageReactionsTable.conversationId)} IS NULL AND ${inSubquery(schema.messageReactionsTable.channelId, channelAccessSubquery)}) OR (${col(schema.messageReactionsTable.conversationId)} IS NOT NULL AND ${inSubquery(schema.messageReactionsTable.conversationId, connectConversationAccessSubquery)}))`, + sql`((${col(T.messageReactions.conversationId)} IS NULL AND ${inSubquery(T.messageReactions.channelId, channelAccessSubquery)}) OR (${col(T.messageReactions.conversationId)} IS NOT NULL AND ${inSubquery(T.messageReactions.conversationId, connectConversationAccessSubquery)}))`, ), ), ), @@ -290,8 +311,8 @@ export function getWhereClauseForTable( Effect.succeed( buildOrgMembershipClause( user.internalUserId, - schema.attachmentsTable.organizationId, - schema.attachmentsTable.deletedAt, + T.attachments.organizationId, + T.attachments.deletedAt, ), ), ), @@ -302,16 +323,12 @@ export function getWhereClauseForTable( Match.when("notifications", () => // Users can only see their own notifications (via subquery on organization_members) - Effect.succeed( - buildUserMembershipClause(user.internalUserId, schema.notificationsTable.memberId), - ), + Effect.succeed(buildUserMembershipClause(user.internalUserId, T.notifications.memberId)), ), Match.when("pinned_messages", () => // Pinned messages: only in channels user has access to - Effect.succeed( - buildChannelAccessClause(user.internalUserId, schema.pinnedMessagesTable.channelId), - ), + Effect.succeed(buildChannelAccessClause(user.internalUserId, T.pinnedMessages.channelId)), ), // =========================================== @@ -320,16 +337,14 @@ export function getWhereClauseForTable( Match.when("typing_indicators", () => // Typing indicators: only in channels user has access to - Effect.succeed( - buildChannelAccessClause(user.internalUserId, schema.typingIndicatorsTable.channelId), - ), + Effect.succeed(buildChannelAccessClause(user.internalUserId, T.typingIndicators.channelId)), ), // =========================================== // Bot tables // =========================================== - Match.when("bots", () => Effect.succeed(buildDeletedAtNullClause(schema.botsTable.deletedAt))), + Match.when("bots", () => Effect.succeed(buildDeletedAtNullClause(T.bots.deletedAt))), Match.when("bot_commands", () => // All bot commands visible (filtered by bot installation in frontend) @@ -338,9 +353,7 @@ export function getWhereClauseForTable( Match.when("bot_installations", () => // Bot installations: only in orgs the user is a member of (no deletedAt column) - Effect.succeed( - buildOrgMembershipClause(user.internalUserId, schema.botInstallationsTable.organizationId), - ), + Effect.succeed(buildOrgMembershipClause(user.internalUserId, T.botInstallations.organizationId)), ), // =========================================== @@ -350,10 +363,7 @@ export function getWhereClauseForTable( Match.when("integration_connections", () => // Integration connections: org-level in user's orgs OR user's own connections Effect.succeed( - buildIntegrationConnectionClause( - user.internalUserId, - schema.integrationConnectionsTable.deletedAt, - ), + buildIntegrationConnectionClause(user.internalUserId, T.integrationConnections.deletedAt), ), ), @@ -365,8 +375,8 @@ export function getWhereClauseForTable( Effect.succeed( buildOrgMembershipClause( user.internalUserId, - schema.customEmojisTable.organizationId, - schema.customEmojisTable.deletedAt, + T.customEmojis.organizationId, + T.customEmojis.deletedAt, ), ), ), diff --git a/apps/electric-proxy/src/tables/where-clause-builder.test.ts b/apps/electric-proxy/src/tables/where-clause-builder.test.ts index b13d413fe..996f6de35 100644 --- a/apps/electric-proxy/src/tables/where-clause-builder.test.ts +++ b/apps/electric-proxy/src/tables/where-clause-builder.test.ts @@ -1,4 +1,3 @@ -import { sql } from "@hazel/db" import { schema } from "@hazel/db" import type { UserId } from "@hazel/schema" import { describe, expect, it } from "vitest" @@ -10,13 +9,15 @@ import { eqCol, inSubquery, isNullCol, + refs, + sql, sqlToWhereClause, WhereClauseParamMismatchError, } from "./where-clause-builder" describe("where-clause-builder channel access", () => { it("buildChannelVisibilityClause uses single channel_access subquery", () => { - const result = buildChannelVisibilityClause("user-1" as UserId, schema.channelsTable.deletedAt) + const result = buildChannelVisibilityClause("user-1" as UserId, refs(schema.channelsTable).deletedAt) expect(result.params).toEqual(["user-1"]) expect(result.whereClause).toContain(`"deletedAt" IS NULL`) @@ -30,8 +31,8 @@ describe("where-clause-builder channel access", () => { it("buildChannelAccessClause includes optional deletedAt and single subquery", () => { const result = buildChannelAccessClause( "user-1" as UserId, - schema.messagesTable.channelId, - schema.messagesTable.deletedAt, + refs(schema.messagesTable).channelId, + refs(schema.messagesTable).deletedAt, ) expect(result.params).toEqual(["user-1"]) @@ -45,11 +46,10 @@ describe("where-clause-builder channel access", () => { }) describe("where-clause-builder sql compiler", () => { - it("compiles drizzle sql to an Electric-compatible where clause", () => { - const channelAccessSubquery = sql`(SELECT ${col(schema.channelAccessTable.channelId)} FROM ${schema.channelAccessTable} WHERE ${eqCol(schema.channelAccessTable.userId, "user-1" as UserId)})` + it("compiles a sql fragment to an Electric-compatible where clause", () => { + const channelAccessSubquery = sql`(SELECT ${col(refs(schema.channelAccessTable).channelId)} FROM ${refs(schema.channelAccessTable).$table} WHERE ${eqCol(refs(schema.channelAccessTable).userId, "user-1" as UserId)})` const result = sqlToWhereClause( - schema.channelsTable, - sql`${isNullCol(schema.channelsTable.deletedAt)} AND ${inSubquery(schema.channelsTable.id, channelAccessSubquery)}`, + sql`${isNullCol(refs(schema.channelsTable).deletedAt)} AND ${inSubquery(refs(schema.channelsTable).id, channelAccessSubquery)}`, ) expect(result.params).toEqual(["user-1"]) diff --git a/apps/electric-proxy/src/tables/where-clause-builder.ts b/apps/electric-proxy/src/tables/where-clause-builder.ts index 2a7486cf7..5466af0f1 100644 --- a/apps/electric-proxy/src/tables/where-clause-builder.ts +++ b/apps/electric-proxy/src/tables/where-clause-builder.ts @@ -1,6 +1,7 @@ -import { schema, sql, type SQL, type SQLWrapper } from "@hazel/db" +import { type PG, schema } from "@hazel/db" import type { UserId } from "@hazel/schema" -import { QueryBuilder, type PgColumn, type PgTable } from "drizzle-orm/pg-core" + +type AnyTable = PG.Table /** * Result of building a WHERE clause with parameterized values @@ -10,8 +11,6 @@ export interface WhereClauseResult { params: unknown[] } -const queryBuilder = new QueryBuilder() - /** * Summary of placeholder/param usage in a WHERE clause. */ @@ -85,35 +84,87 @@ export function assertWhereClauseParamsAreSequential(result: WhereClauseResult): } } -const getRootTable = (column: PgColumn): PgTable => column.table as PgTable +/** A table, written as its quoted name. */ +export interface TableRef { + readonly _tag: "TableRef" + readonly name: string +} -export const col = (column: PgColumn) => sql.identifier(column.name) +/** + * A column, written as its quoted, unqualified name: Electric requires unqualified column names in a + * shape's WHERE clause. + */ +export interface ColumnRef { + readonly _tag: "ColumnRef" + readonly table: TableRef + readonly name: string +} -export const eqCol = (column: PgColumn, value: T | SQLWrapper): SQL => sql`${col(column)} = ${value}` +/** SQL text with bound values, spliced columns, tables and nested fragments. */ +export interface SQL { + readonly _tag: "SQL" + readonly strings: ReadonlyArray + readonly values: ReadonlyArray +} -export const isNullCol = (column: PgColumn): SQL => sql`${col(column)} IS NULL` +const isTagged = (value: unknown, tag: Tag): value is { readonly _tag: Tag } => + typeof value === "object" && value !== null && (value as { _tag?: unknown })._tag === tag + +/** A fragment: `${value}` is bound, `${columnRef}`/`${tableRef}` are quoted names, `${sql}` is spliced. */ +export const sql = (strings: TemplateStringsArray, ...values: ReadonlyArray): SQL => ({ + _tag: "SQL", + strings: [...strings], + values, +}) + +/** A table's reference and its columns', by key, read from its effect-orm definition. */ +export const refs = ( + table: T, +): { readonly [K in keyof T["columns"] & string]: ColumnRef } & { + readonly $table: TableRef +} => { + const tableRef: TableRef = { _tag: "TableRef", name: table.name } + const columns: Record = { $table: tableRef } + for (const [key, type] of Object.entries(table.columns as Record)) { + columns[key] = { _tag: "ColumnRef", table: tableRef, name: type.sqlName ?? key } + } + return columns as never +} -export const inSubquery = (column: PgColumn, subquerySql: SQL): SQL => sql`${col(column)} IN ${subquerySql}` +const quote = (name: string) => `"${name.replaceAll('"', '""')}"` + +/** Renders a fragment with `$1, $2, ...` placeholders in the order values appear. */ +const render = (fragment: SQL, params: Array): string => { + let text = fragment.strings[0] ?? "" + fragment.values.forEach((value, index) => { + if (isTagged(value, "SQL")) text += render(value as SQL, params) + else if (isTagged(value, "ColumnRef") || isTagged(value, "TableRef")) + text += quote((value as ColumnRef).name) + else { + params.push(value) + text += `$${params.length}` + } + text += fragment.strings[index + 1] ?? "" + }) + return text +} -const comma = sql.raw(", ") +export const col = (column: ColumnRef): SQL => sql`${column}` -const buildParamList = (values: readonly unknown[]): SQL => - sql`(${sql.join( - values.map((value) => sql`${value}`), - comma, - )})` +export const eqCol = (column: ColumnRef, value: T | SQL): SQL => sql`${column} = ${value}` -const buildSubquery = (selectedColumn: PgColumn, table: PgTable, whereExpr: SQL): SQL => - sql`(SELECT ${col(selectedColumn)} FROM ${table} WHERE ${whereExpr})` +export const isNullCol = (column: ColumnRef): SQL => sql`${column} IS NULL` -const extractWhereClause = (compiledSql: string): string => { - const match = /\bwhere\b\s+([\s\S]*)$/i.exec(compiledSql) - if (!match?.[1]) { - throw new Error(`Failed to extract WHERE clause from compiled SQL: ${compiledSql}`) - } - return match[1].trim() +export const inSubquery = (column: ColumnRef, subquerySql: SQL): SQL => sql`${column} IN ${subquerySql}` + +const buildParamList = (values: readonly unknown[]): SQL => { + const strings = ["(", ...values.slice(1).map(() => ", "), ")"] + return { _tag: "SQL", strings, values } } +const buildSubquery = (selectedColumn: ColumnRef, whereExpr: SQL): SQL => + sql`(SELECT ${selectedColumn} FROM ${selectedColumn.table} WHERE ${whereExpr})` + const dedupeParams = (whereClause: string, params: unknown[]): WhereClauseResult => { const dedupedParams: unknown[] = [] const placeholderMap = new Map() @@ -140,59 +191,51 @@ const dedupeParams = (whereClause: string, params: unknown[]): WhereClauseResult } } -export function sqlToWhereClause( - rootTable: PgTable, - whereExpr: SQL, - overrideParams?: unknown[], -): WhereClauseResult { - const compiled = queryBuilder - .select({ __electric_where__: sql`1` }) - .from(rootTable) - .where(whereExpr) - .toSQL() - const result = { - whereClause: extractWhereClause(compiled.sql), - params: overrideParams ?? compiled.params, - } - - return dedupeParams(result.whereClause, result.params) +/** Renders a WHERE expression as Electric takes it: placeholders from `$1`, one per distinct value. */ +export function sqlToWhereClause(whereExpr: SQL): WhereClauseResult { + const params: Array = [] + const whereClause = render(whereExpr, params).trim() + return dedupeParams(whereClause, params) } +const channelAccess = refs(schema.channelAccessTable) +const organizationMembers = refs(schema.organizationMembersTable) +const channels = refs(schema.channelsTable) + /** * Build IN clause with sorted IDs using unqualified column name. * Uses column.name for Electric SQL compatibility (Electric requires unqualified column names). * - * @param column - The Drizzle column to filter on (uses column.name for unqualified name) + * @param column - The column to filter on (uses column.name for unqualified name) * @param values - Array of values for the IN clause * @returns WhereClauseResult with parameterized WHERE clause */ -export function buildInClause(column: PgColumn, values: readonly T[]): WhereClauseResult { +export function buildInClause(column: ColumnRef, values: readonly T[]): WhereClauseResult { if (values.length === 0) { return { whereClause: "false", params: [] } } const sorted = [...values].sort() - return sqlToWhereClause(getRootTable(column), sql`${col(column)} IN ${buildParamList(sorted)}`) + return sqlToWhereClause(sql`${col(column)} IN ${buildParamList(sorted)}`) } /** * Build IN clause with deletedAt IS NULL check using unqualified column names. * - * @param column - The Drizzle column to filter on + * @param column - The column to filter on * @param values - Array of values for the IN clause * @param deletedAtColumn - The deletedAt column to check for NULL * @returns WhereClauseResult with parameterized WHERE clause */ export function buildInClauseWithDeletedAt( - column: PgColumn, + column: ColumnRef, values: readonly T[], - deletedAtColumn: PgColumn, + deletedAtColumn: ColumnRef, ): WhereClauseResult { if (values.length === 0) { return { whereClause: "false", params: [] } } const sorted = [...values].sort() return sqlToWhereClause( - getRootTable(column), sql`${col(column)} IN ${buildParamList(sorted)} AND ${isNullCol(deletedAtColumn)}`, ) } @@ -200,13 +243,13 @@ export function buildInClauseWithDeletedAt( /** * Build equality check using unqualified column name. * - * @param column - The Drizzle column to filter on + * @param column - The column to filter on * @param value - The value to compare against * @param paramIndex - The parameter index (default: 1) * @returns WhereClauseResult with parameterized WHERE clause */ -export function buildEqClause(column: PgColumn, value: T, paramIndex = 1): WhereClauseResult { - const result = sqlToWhereClause(getRootTable(column), eqCol(column, value)) +export function buildEqClause(column: ColumnRef, value: T, paramIndex = 1): WhereClauseResult { + const result = sqlToWhereClause(eqCol(column, value)) if (paramIndex === 1) { return result } @@ -223,8 +266,8 @@ export function buildEqClause(column: PgColumn, value: T, paramIndex = 1): Wh * @param deletedAtColumn - The deletedAt column to check for NULL * @returns WhereClauseResult with no parameters */ -export function buildDeletedAtNullClause(deletedAtColumn: PgColumn): WhereClauseResult { - return sqlToWhereClause(getRootTable(deletedAtColumn), isNullCol(deletedAtColumn)) +export function buildDeletedAtNullClause(deletedAtColumn: ColumnRef): WhereClauseResult { + return sqlToWhereClause(isNullCol(deletedAtColumn)) } /** @@ -249,16 +292,11 @@ export function buildNoFilterClause(): WhereClauseResult { * @param deletedAtColumn - The deletedAt column to check for NULL * @returns WhereClauseResult with parameterized WHERE clause and subquery */ -export function buildChannelVisibilityClause(userId: UserId, deletedAtColumn: PgColumn): WhereClauseResult { - const channelAccessSubquery = buildSubquery( - schema.channelAccessTable.channelId, - schema.channelAccessTable, - eqCol(schema.channelAccessTable.userId, userId), - ) +export function buildChannelVisibilityClause(userId: UserId, deletedAtColumn: ColumnRef): WhereClauseResult { + const channelAccessSubquery = buildSubquery(channelAccess.channelId, eqCol(channelAccess.userId, userId)) return sqlToWhereClause( - getRootTable(deletedAtColumn), - sql`${isNullCol(deletedAtColumn)} AND ${inSubquery(schema.channelsTable.id, channelAccessSubquery)}`, + sql`${isNullCol(deletedAtColumn)} AND ${inSubquery(channels.id, channelAccessSubquery)}`, ) } @@ -275,13 +313,12 @@ export function buildChannelVisibilityClause(userId: UserId, deletedAtColumn: Pg */ export function buildOrgMembershipClause( userId: UserId, - orgIdColumn: PgColumn, - deletedAtColumn?: PgColumn, + orgIdColumn: ColumnRef, + deletedAtColumn?: ColumnRef, ): WhereClauseResult { const orgMembershipSubquery = buildSubquery( - schema.organizationMembersTable.organizationId, - schema.organizationMembersTable, - sql`${eqCol(schema.organizationMembersTable.userId, userId)} AND ${isNullCol(schema.organizationMembersTable.deletedAt)}`, + organizationMembers.organizationId, + sql`${eqCol(organizationMembers.userId, userId)} AND ${isNullCol(organizationMembers.deletedAt)}`, ) const baseCondition = inSubquery(orgIdColumn, orgMembershipSubquery) @@ -289,7 +326,7 @@ export function buildOrgMembershipClause( ? sql`${isNullCol(deletedAtColumn)} AND ${baseCondition}` : baseCondition - return sqlToWhereClause(getRootTable(orgIdColumn), whereExpr) + return sqlToWhereClause(whereExpr) } /** @@ -305,22 +342,19 @@ export function buildOrgMembershipClause( */ export function buildUserOrgMembershipClause( userId: UserId, - userIdColumn: PgColumn, - deletedAtColumn: PgColumn, + userIdColumn: ColumnRef, + deletedAtColumn: ColumnRef, ): WhereClauseResult { const currentUserOrgIds = buildSubquery( - schema.organizationMembersTable.organizationId, - schema.organizationMembersTable, - sql`${eqCol(schema.organizationMembersTable.userId, userId)} AND ${isNullCol(schema.organizationMembersTable.deletedAt)}`, + organizationMembers.organizationId, + sql`${eqCol(organizationMembers.userId, userId)} AND ${isNullCol(organizationMembers.deletedAt)}`, ) const sharedOrgUsers = buildSubquery( - schema.organizationMembersTable.userId, - schema.organizationMembersTable, - sql`${col(schema.organizationMembersTable.organizationId)} IN ${currentUserOrgIds} AND ${isNullCol(schema.organizationMembersTable.deletedAt)}`, + organizationMembers.userId, + sql`${col(organizationMembers.organizationId)} IN ${currentUserOrgIds} AND ${isNullCol(organizationMembers.deletedAt)}`, ) return sqlToWhereClause( - getRootTable(userIdColumn), sql`${isNullCol(deletedAtColumn)} AND ${inSubquery(userIdColumn, sharedOrgUsers)}`, ) } @@ -335,14 +369,13 @@ export function buildUserOrgMembershipClause( * @param memberIdColumn - The memberId column to filter on * @returns WhereClauseResult with parameterized WHERE clause and subquery */ -export function buildUserMembershipClause(userId: UserId, memberIdColumn: PgColumn): WhereClauseResult { +export function buildUserMembershipClause(userId: UserId, memberIdColumn: ColumnRef): WhereClauseResult { const membershipSubquery = buildSubquery( - schema.organizationMembersTable.id, - schema.organizationMembersTable, - sql`${eqCol(schema.organizationMembersTable.userId, userId)} AND ${isNullCol(schema.organizationMembersTable.deletedAt)}`, + organizationMembers.id, + sql`${eqCol(organizationMembers.userId, userId)} AND ${isNullCol(organizationMembers.deletedAt)}`, ) - return sqlToWhereClause(getRootTable(memberIdColumn), inSubquery(memberIdColumn, membershipSubquery)) + return sqlToWhereClause(inSubquery(memberIdColumn, membershipSubquery)) } /** @@ -358,20 +391,16 @@ export function buildUserMembershipClause(userId: UserId, memberIdColumn: PgColu */ export function buildChannelAccessClause( userId: UserId, - channelIdColumn: PgColumn, - deletedAtColumn?: PgColumn, + channelIdColumn: ColumnRef, + deletedAtColumn?: ColumnRef, ): WhereClauseResult { - const channelAccessSubquery = buildSubquery( - schema.channelAccessTable.channelId, - schema.channelAccessTable, - eqCol(schema.channelAccessTable.userId, userId), - ) + const channelAccessSubquery = buildSubquery(channelAccess.channelId, eqCol(channelAccess.userId, userId)) const baseCondition = inSubquery(channelIdColumn, channelAccessSubquery) const whereExpr = deletedAtColumn ? sql`${isNullCol(deletedAtColumn)} AND ${baseCondition}` : baseCondition - return sqlToWhereClause(getRootTable(channelIdColumn), whereExpr) + return sqlToWhereClause(whereExpr) } /** @@ -386,17 +415,15 @@ export function buildChannelAccessClause( */ export function buildIntegrationConnectionClause( userId: UserId, - deletedAtColumn: PgColumn, + deletedAtColumn: ColumnRef, ): WhereClauseResult { const orgMembershipSubquery = buildSubquery( - schema.organizationMembersTable.organizationId, - schema.organizationMembersTable, - sql`${eqCol(schema.organizationMembersTable.userId, userId)} AND ${isNullCol(schema.organizationMembersTable.deletedAt)}`, + organizationMembers.organizationId, + sql`${eqCol(organizationMembers.userId, userId)} AND ${isNullCol(organizationMembers.deletedAt)}`, ) return sqlToWhereClause( - getRootTable(deletedAtColumn), - sql`${isNullCol(deletedAtColumn)} AND ((${sql.identifier("userId")} IS NULL AND ${sql.identifier("organizationId")} IN ${orgMembershipSubquery}) OR ${sql.identifier("userId")} = ${userId})`, + sql`${isNullCol(deletedAtColumn)} AND (("userId" IS NULL AND "organizationId" IN ${orgMembershipSubquery}) OR "userId" = ${userId})`, ) } diff --git a/apps/electric-proxy/src/worker.ts b/apps/electric-proxy/src/worker.ts index 11856ece6..165954379 100644 --- a/apps/electric-proxy/src/worker.ts +++ b/apps/electric-proxy/src/worker.ts @@ -14,7 +14,7 @@ import { forIsolate, isolateContext } from "@hazel/infra/worker-http" import { workerEnvLayer } from "@hazel/infra/worker-runtime" import type { KVNamespace } from "@cloudflare/workers-types" import * as Cloudflare from "alchemy/Cloudflare" -import { Config, Effect, Layer, Logger, Option, Redacted, Schema } from "effect" +import { Config, Effect, Layer, Logger, Option, Redacted, Schema, Scope } from "effect" import { HttpServerRequest, HttpServerResponse } from "effect/http" import { electricProxyEnv, mapleObservability, ProxyCache } from "../resources.ts" import { AccessContextCacheService } from "./cache" @@ -134,19 +134,19 @@ export default class ElectricProxy extends Cloudflare.Worker()( // No binding: `Database.layerRequestScoped` names the missing connection on first use. if (Option.isNone(binding)) return effect return Effect.acquireUseRelease( - Effect.sync(() => Database.makeRequestConnection(binding.value.connectionString)), - (connection) => - Effect.provideService(effect, Database.DatabaseConnection, { db: connection.db }), - // Only auth and where-clauses touch the database, so the socket closes before the - // (possibly long-polling) body streams; `waitUntil` keeps the close off the response. - (connection) => - exec.waitUntil( - Effect.tryPromise(() => connection.end()).pipe( - Effect.catchTag("UnknownError", (error) => - Effect.logWarning("Failed to close the request's Postgres client", error), - ), + Scope.make(), + (scope) => + Effect.provideServiceEffect( + effect, + Database.DatabaseConnection, + Database.makeRequestConnection(binding.value.connectionString).pipe( + Scope.provide(scope), + Effect.orDie, ), ), + // Only auth and where-clauses touch the database, so the socket closes before the + // (possibly long-polling) body streams; `waitUntil` keeps the close off the response. + (scope, exit) => exec.waitUntil(Scope.close(scope, exit)), ) } diff --git a/bots/linear-bot/package.json b/bots/linear-bot/package.json index 53088e110..b04475b08 100644 --- a/bots/linear-bot/package.json +++ b/bots/linear-bot/package.json @@ -14,7 +14,6 @@ "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/integrations": "workspace:*", - "drizzle-orm": "^0.45.1", "effect": "catalog:effect" }, "devDependencies": { diff --git a/bun.lock b/bun.lock index a31596591..9df8408fb 100644 --- a/bun.lock +++ b/bun.lock @@ -415,7 +415,6 @@ "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/integrations": "workspace:*", - "drizzle-orm": "^0.45.1", "effect": "catalog:effect", }, "devDependencies": { @@ -525,7 +524,6 @@ "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/schema": "workspace:*", - "drizzle-orm": "^0.45.1", "effect": "catalog:effect", }, "devDependencies": { @@ -545,14 +543,11 @@ "@hazel/domain": "workspace:*", "@hazel/schema": "workspace:*", "@maple-dev/effect-orm": "0.5.0", - "drizzle-orm": "^0.45.1", "effect": "catalog:effect", - "postgres": "^3.4.7", }, "devDependencies": { "@testcontainers/postgresql": "^10.18.0", "@types/bun": "1.3.9", - "drizzle-kit": "^0.31.8", "typescript": "^5.9.3", }, }, @@ -562,7 +557,6 @@ "dependencies": { "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", - "drizzle-orm": "^0.45.1", "effect": "catalog:effect", }, "devDependencies": { @@ -4709,7 +4703,7 @@ "@grpc/proto-loader/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], - "@hazel/effect-bun/@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + "@hazel/effect-bun/@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], "@hazel/electric-proxy/@electric-sql/client": ["@electric-sql/client@1.5.15", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.18.1" }, "bin": { "intent": "bin/intent.mjs" } }, "sha512-8C+mqZu6r68kU/jf63FLuc90M2ejyeTgB/68G0ufX4H2WepQw/NJXrIY3veE+sLrDGL+uyQB+fDStHH30fi8qg=="], @@ -5569,7 +5563,7 @@ "@grpc/proto-loader/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "@hazel/effect-bun/@types/bun/bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "@hazel/effect-bun/@types/bun/bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], "@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], diff --git a/infra/README.md b/infra/README.md index 32d35abc9..4d39f039d 100644 --- a/infra/README.md +++ b/infra/README.md @@ -59,7 +59,7 @@ Worker env (secrets are uploaded as Worker secrets, plain values as vars) is lis 1. **Plan first.** `bunx alchemy plan --stage prd` and check that the existing Workers, KV namespaces and custom domains show as _adopt/update_, not _create/replace_. 2. **Database.** The first deploy creates the PlanetScale database `hazel` and its roles. - - Load the schema with `drizzle-kit push` (packages/db) against the API role's URL. + - Load the schema with `bun run db:migrate` (packages/db) against the API role's URL. - Copy the data over in a maintenance window (`pg_dump` from the current host, then restore). - Repoint the Railway services (backend, cluster) at PlanetScale first, so the database moves before any compute does. diff --git a/libs/bot-sdk/tsdown.config.ts b/libs/bot-sdk/tsdown.config.ts index 69ef2924e..b5e667075 100644 --- a/libs/bot-sdk/tsdown.config.ts +++ b/libs/bot-sdk/tsdown.config.ts @@ -17,7 +17,6 @@ export default defineConfig({ "jose", /^rivetkit/, "react", - "drizzle-orm", "@linear/sdk", "he", "rss-parser", diff --git a/packages/auth/src/consumers/proxy-auth.ts b/packages/auth/src/consumers/proxy-auth.ts index 5a99ead74..f87fb27ff 100644 --- a/packages/auth/src/consumers/proxy-auth.ts +++ b/packages/auth/src/consumers/proxy-auth.ts @@ -1,6 +1,6 @@ import { verifyToken } from "@clerk/backend" -import { Database, eq, schema } from "@hazel/db" -import { ClerkJwtClaims } from "@hazel/schema" +import { Database, PG, schema } from "@hazel/db" +import { ClerkJwtClaims, type UserId } from "@hazel/schema" import { Context, Config, Effect, Layer, Option, Redacted, Schema } from "effect" import { UserLookupCache } from "../cache/user-lookup-cache.ts" import type { AuthenticatedUserContext } from "../types.ts" @@ -28,31 +28,28 @@ export class ProxyAuth extends Context.Service()("@hazel/auth/ProxyAu const clerkSecretKey = yield* Config.Redacted("CLERK_SECRET_KEY") const lookupUser = Effect.fn("ProxyAuth.lookupUser")(function* (externalId: string) { - const cached = yield* userLookupCache - .get(externalId) - .pipe( - Effect.catch((error) => - Effect.logWarning("User lookup cache error", error).pipe( - Effect.map(() => - Option.none<{ - internalUserId: (typeof schema.usersTable.$inferSelect)["id"] - }>(), - ), + const cached = yield* userLookupCache.get(externalId).pipe( + Effect.catch((error) => + Effect.logWarning("User lookup cache error", error).pipe( + Effect.map(() => + Option.none<{ + internalUserId: UserId + }>(), ), ), - ) + ), + ) if (Option.isSome(cached)) { yield* Effect.annotateCurrentSpan("cache.result", "hit") return Option.some(cached.value.internalUserId) } - const userResult = yield* db - .execute((client) => - client - .select({ id: schema.usersTable.id }) - .from(schema.usersTable) - .where(eq(schema.usersTable.externalId, externalId)) + const userOption = yield* db + .first( + PG.from(schema.usersTable) + .select("id") + .where(($) => [$.externalId.eq(externalId)]) .limit(1), ) .pipe( @@ -65,7 +62,6 @@ export class ProxyAuth extends Context.Service()("@hazel/auth/ProxyAu ), ), ) - const userOption = Option.fromNullishOr(userResult[0]) if (Option.isSome(userOption)) { yield* userLookupCache diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index 4505cab5a..ccc533e63 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -17,7 +17,6 @@ "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/schema": "workspace:*", - "drizzle-orm": "^0.45.1", "effect": "catalog:effect" }, "devDependencies": { diff --git a/packages/backend-core/src/repositories/bot-command-repo.ts b/packages/backend-core/src/repositories/bot-command-repo.ts index 833487f9a..0cadd55a7 100644 --- a/packages/backend-core/src/repositories/bot-command-repo.ts +++ b/packages/backend-core/src/repositories/bot-command-repo.ts @@ -1,8 +1,8 @@ -import { and, Database, eq, inArray, lt, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" -import type { BotCommandId, BotId } from "@hazel/schema" +import type { BotId } from "@hazel/schema" import { BotCommand } from "@hazel/domain/models" -import { Context, Effect, Layer, Option } from "effect" +import { Context, Effect, Layer } from "effect" export class BotCommandRepo extends Context.Service()("BotCommandRepo", { make: Effect.gen(function* () { @@ -17,140 +17,90 @@ export class BotCommandRepo extends Context.Service()("BotComman const db = yield* Database.Database // Find all commands for a bot - const findByBot = (botId: BotId, tx?: TxFn) => - db.makeQuery((execute, data: { botId: BotId }) => - execute((client) => - client - .select() - .from(schema.botCommandsTable) - .where(eq(schema.botCommandsTable.botId, data.botId)), - ), - )({ botId }, tx) + const findByBot = (botId: BotId) => + db.run( + PG.from(schema.botCommandsTable) + .select() + .where(($) => [$.botId.eq(botId)]), + ) // Find enabled commands for a bot - const findEnabledByBot = (botId: BotId, tx?: TxFn) => - db.makeQuery((execute, data: { botId: BotId }) => - execute((client) => - client - .select() - .from(schema.botCommandsTable) - .where( - and( - eq(schema.botCommandsTable.botId, data.botId), - eq(schema.botCommandsTable.isEnabled, true), - ), - ), - ), - )({ botId }, tx) + const findEnabledByBot = (botId: BotId) => + db.run( + PG.from(schema.botCommandsTable) + .select() + .where(($) => [$.botId.eq(botId), $.isEnabled.eq(true)]), + ) // Find commands for multiple bots (for getAvailableCommands) - const findByBots = (botIds: BotId[], tx?: TxFn) => { + const findByBots = (botIds: BotId[]) => { if (botIds.length === 0) { return Effect.succeed([]) } - return db.makeQuery((execute, data: { botIds: BotId[] }) => - execute((client) => - client - .select() - .from(schema.botCommandsTable) - .where( - and( - inArray(schema.botCommandsTable.botId, data.botIds), - eq(schema.botCommandsTable.isEnabled, true), - ), - ), - ), - )({ botIds }, tx) + return db.run( + PG.from(schema.botCommandsTable) + .select() + .where(($) => [$.botId.in_(...botIds), $.isEnabled.eq(true)]), + ) } // Find a specific command by bot and name - const findByBotAndName = (botId: BotId, name: string, tx?: TxFn) => - db - .makeQuery((execute, data: { botId: BotId; name: string }) => - execute((client) => - client - .select() - .from(schema.botCommandsTable) - .where( - and( - eq(schema.botCommandsTable.botId, data.botId), - eq(schema.botCommandsTable.name, data.name), - ), - ) - .limit(1), - ), - )({ botId, name }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByBotAndName = (botId: BotId, name: string) => + db.first( + PG.from(schema.botCommandsTable) + .select() + .where(($) => [$.botId.eq(botId), $.name.eq(name)]) + .limit(1), + ) // Upsert command (for bot sync) - const upsert = ( - data: { - botId: BotId - name: string - description: string - arguments: BotCommand.BotCommandArgument[] | null - usageExample: string | null - }, - tx?: TxFn, - ) => - db - .makeQuery( - ( - execute, - d: { - botId: BotId - name: string - description: string - arguments: BotCommand.BotCommandArgument[] | null - usageExample: string | null - }, - ) => - execute((client) => { - // Convert null values to undefined for DB schema compatibility - const args = (d.arguments ?? []).map((arg) => ({ - ...arg, - description: arg.description ?? undefined, - placeholder: arg.placeholder ?? undefined, - })) - return client - .insert(schema.botCommandsTable) - .values({ - botId: d.botId, - name: d.name, - description: d.description, - arguments: args, - usageExample: d.usageExample, - isEnabled: true, - }) - .onConflictDoUpdate({ - target: [schema.botCommandsTable.botId, schema.botCommandsTable.name], - set: { - description: d.description, - arguments: args, - usageExample: d.usageExample, - isEnabled: true, - updatedAt: new Date(), - }, - }) - .returning() - }), - )(data, tx) + const upsert = (data: { + botId: BotId + name: string + description: string + arguments: BotCommand.BotCommandArgument[] | null + usageExample: string | null + }) => { + // Convert null values to undefined for DB schema compatibility + const args = (data.arguments ?? []).map((arg) => ({ + ...arg, + description: arg.description ?? undefined, + placeholder: arg.placeholder ?? undefined, + })) + return db + .run( + PG.insertInto(schema.botCommandsTable) + .values({ + botId: data.botId, + name: data.name, + description: data.description, + arguments: args, + usageExample: data.usageExample, + isEnabled: true, + }) + .onConflictDoUpdate({ + target: ["botId", "name"], + set: { + description: data.description, + arguments: args, + usageExample: data.usageExample, + isEnabled: true, + updatedAt: new Date(), + }, + }) + .returning(), + ) .pipe(Effect.map((results) => results[0])) + } // Delete commands not updated since the given timestamp (for bot sync cleanup) - const deleteStaleCommands = (botId: BotId, syncStartTime: Date, tx?: TxFn) => - db.makeQuery((execute, data: { botId: BotId; syncStartTime: Date }) => - execute((client) => - client - .delete(schema.botCommandsTable) - .where( - and( - eq(schema.botCommandsTable.botId, data.botId), - lt(schema.botCommandsTable.updatedAt, data.syncStartTime), - ), - ), - ), - )({ botId, syncStartTime }, tx) + const deleteStaleCommands = (botId: BotId, syncStartTime: Date) => + db.run( + PG.deleteFrom(schema.botCommandsTable).where(($) => [ + $.botId.eq(botId), + $.updatedAt.lt(syncStartTime), + ]), + ) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/bot-installation-repo.ts b/packages/backend-core/src/repositories/bot-installation-repo.ts index 6aed92182..3b3539507 100644 --- a/packages/backend-core/src/repositories/bot-installation-repo.ts +++ b/packages/backend-core/src/repositories/bot-installation-repo.ts @@ -1,6 +1,6 @@ -import { and, Database, eq, inArray, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" -import type { BotId, BotInstallationId, OrganizationId } from "@hazel/schema" +import type { BotId, OrganizationId } from "@hazel/schema" import { BotInstallation } from "@hazel/domain/models" import { Context, Effect, Layer, Option } from "effect" @@ -17,55 +17,37 @@ export class BotInstallationRepo extends Context.Service()( const db = yield* Database.Database // Find all installations for an organization - const findByOrg = (organizationId: OrganizationId, tx?: TxFn) => - db.makeQuery((execute, data: { organizationId: OrganizationId }) => - execute((client) => - client - .select() - .from(schema.botInstallationsTable) - .where(eq(schema.botInstallationsTable.organizationId, data.organizationId)), - ), - )({ organizationId }, tx) + const findByOrg = (organizationId: OrganizationId) => + db.run( + PG.from(schema.botInstallationsTable) + .select() + .where(($) => [$.organizationId.eq(organizationId)]), + ) // Find all installations for a bot - const findByBot = (botId: BotId, tx?: TxFn) => - db.makeQuery((execute, data: { botId: BotId }) => - execute((client) => - client - .select() - .from(schema.botInstallationsTable) - .where(eq(schema.botInstallationsTable.botId, data.botId)), - ), - )({ botId }, tx) + const findByBot = (botId: BotId) => + db.run( + PG.from(schema.botInstallationsTable) + .select() + .where(($) => [$.botId.eq(botId)]), + ) // Find a specific installation - const findByBotAndOrg = (botId: BotId, organizationId: OrganizationId, tx?: TxFn) => - db - .makeQuery((execute, data: { botId: BotId; organizationId: OrganizationId }) => - execute((client) => - client - .select() - .from(schema.botInstallationsTable) - .where( - and( - eq(schema.botInstallationsTable.botId, data.botId), - eq(schema.botInstallationsTable.organizationId, data.organizationId), - ), - ) - .limit(1), - ), - )({ botId, organizationId }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByBotAndOrg = (botId: BotId, organizationId: OrganizationId) => + db.first( + PG.from(schema.botInstallationsTable) + .select() + .where(($) => [$.botId.eq(botId), $.organizationId.eq(organizationId)]) + .limit(1), + ) // Check if a bot is installed in an organization - const isInstalled = (botId: BotId, organizationId: OrganizationId, tx?: TxFn) => - findByBotAndOrg(botId, organizationId, tx).pipe(Effect.map(Option.isSome)) + const isInstalled = (botId: BotId, organizationId: OrganizationId) => + findByBotAndOrg(botId, organizationId).pipe(Effect.map(Option.isSome)) // Get bot IDs installed in an organization - const getBotIdsForOrg = (organizationId: OrganizationId, tx?: TxFn) => - findByOrg(organizationId, tx).pipe( - Effect.map((installations) => installations.map((i) => i.botId)), - ) + const getBotIdsForOrg = (organizationId: OrganizationId) => + findByOrg(organizationId).pipe(Effect.map((installations) => installations.map((i) => i.botId))) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/bot-repo.ts b/packages/backend-core/src/repositories/bot-repo.ts index 2da974ea2..94b55aaa6 100644 --- a/packages/backend-core/src/repositories/bot-repo.ts +++ b/packages/backend-core/src/repositories/bot-repo.ts @@ -1,8 +1,8 @@ -import { and, Database, eq, ilike, inArray, isNull, Repository, or, schema, sql, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { BotId, UserId } from "@hazel/schema" import { Bot } from "@hazel/domain/models" -import { Context, Effect, Layer, Option, type Schema } from "effect" +import { Context, Effect, Layer, type Schema } from "effect" export class BotRepo extends Context.Service()("BotRepo", { make: Effect.gen(function* () { @@ -17,225 +17,153 @@ export class BotRepo extends Context.Service()("BotRepo", { const db = yield* Database.Database // Find bot by ID - const findById = (id: BotId, tx?: TxFn) => - db - .makeQuery((execute, data: { id: BotId }) => - execute((client) => - client - .select() - .from(schema.botsTable) - .where(and(eq(schema.botsTable.id, data.id), isNull(schema.botsTable.deletedAt))) - .limit(1), - ), - )({ id }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findById = (id: BotId) => + db.first( + PG.from(schema.botsTable) + .select() + .where(($) => [$.id.eq(id), $.deletedAt.isNull()]) + .limit(1), + ) // Find bot by token hash - const findByTokenHash = (tokenHash: string, tx?: TxFn) => - db - .makeQuery((execute, data: { tokenHash: string }) => - execute((client) => - client - .select() - .from(schema.botsTable) - .where( - and( - eq(schema.botsTable.apiTokenHash, data.tokenHash), - isNull(schema.botsTable.deletedAt), - ), - ) - .limit(1), - ), - )({ tokenHash }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByTokenHash = (tokenHash: string) => + db.first( + PG.from(schema.botsTable) + .select() + .where(($) => [$.apiTokenHash.eq(tokenHash), $.deletedAt.isNull()]) + .limit(1), + ) // Find bot by user ID - const findByUserId = (userId: UserId, tx?: TxFn) => - db - .makeQuery((execute, data: { userId: UserId }) => - execute((client) => - client - .select() - .from(schema.botsTable) - .where( - and( - eq(schema.botsTable.userId, data.userId), - isNull(schema.botsTable.deletedAt), - ), - ) - .limit(1), - ), - )({ userId }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByUserId = (userId: UserId) => + db.first( + PG.from(schema.botsTable) + .select() + .where(($) => [$.userId.eq(userId), $.deletedAt.isNull()]) + .limit(1), + ) // Find all bots created by a user - const findByCreator = (createdBy: UserId, tx?: TxFn) => - db.makeQuery((execute, data: { createdBy: UserId }) => - execute((client) => - client - .select() - .from(schema.botsTable) - .where( - and( - eq(schema.botsTable.createdBy, data.createdBy), - isNull(schema.botsTable.deletedAt), - ), - ), - ), - )({ createdBy }, tx) + const findByCreator = (createdBy: UserId) => + db.run( + PG.from(schema.botsTable) + .select() + .where(($) => [$.createdBy.eq(createdBy), $.deletedAt.isNull()]), + ) // Find all public bots (for marketplace) - const findPublic = (search?: string, tx?: TxFn) => - db.makeQuery((execute, data: { search?: string }) => - execute((client) => { - const conditions = [ - eq(schema.botsTable.isPublic, true), - isNull(schema.botsTable.deletedAt), - ] - - if (data.search) { - conditions.push( - or( - ilike(schema.botsTable.name, `%${data.search}%`), - ilike(schema.botsTable.description, `%${data.search}%`), - )!, - ) - } - - return client - .select() - .from(schema.botsTable) - .where(and(...conditions)) - .orderBy(schema.botsTable.installCount) - }), - )({ search }, tx) + const findPublic = (search?: string) => + db.run( + PG.from(schema.botsTable) + .select() + .where(($) => [ + $.isPublic.eq(true), + $.deletedAt.isNull(), + search + ? $.name.ilike(`%${search}%`).or($.description.ilike(`%${search}%`)) + : undefined, + ]) + .orderBy(["installCount", "asc"]), + ) // Find bots by IDs (for installed bots lookup) - const findByIds = (ids: BotId[], tx?: TxFn) => { + const findByIds = (ids: BotId[]) => { if (ids.length === 0) { return Effect.succeed([]) } - return db.makeQuery((execute, data: { ids: BotId[] }) => - execute((client) => - client - .select() - .from(schema.botsTable) - .where( - and(inArray(schema.botsTable.id, data.ids), isNull(schema.botsTable.deletedAt)), - ), - ), - )({ ids }, tx) + return db.run( + PG.from(schema.botsTable) + .select() + .where(($) => [$.id.in_(...ids), $.deletedAt.isNull()]), + ) } // Insert a new bot - const insert = (data: Schema.Schema.Type, tx?: TxFn) => - db.makeQuery((execute, input: typeof data) => - execute((client) => - client - .insert(schema.botsTable) - .values({ - userId: input.userId, - createdBy: input.createdBy, - name: input.name, - description: input.description, - webhookUrl: input.webhookUrl, - apiTokenHash: input.apiTokenHash, - scopes: input.scopes ? [...input.scopes] : null, - metadata: input.metadata, - isPublic: input.isPublic, - installCount: input.installCount, - allowedIntegrations: input.allowedIntegrations - ? [...input.allowedIntegrations] - : null, - }) - .returning(), - ), - )(data, tx) + const insert = (data: Schema.Schema.Type) => + db.run( + PG.insertInto(schema.botsTable) + .values({ + userId: data.userId, + createdBy: data.createdBy, + name: data.name, + description: data.description, + webhookUrl: data.webhookUrl, + apiTokenHash: data.apiTokenHash, + scopes: data.scopes ? [...data.scopes] : null, + metadata: data.metadata, + isPublic: data.isPublic, + installCount: data.installCount, + allowedIntegrations: data.allowedIntegrations ? [...data.allowedIntegrations] : null, + }) + .returning(), + ) // Update a bot const update = ( data: { id: BotId } & Partial< Omit, "id" | "createdAt" | "updatedAt" | "deletedAt"> >, - tx?: TxFn, ) => db - .makeQuery((execute, input: typeof data) => - execute((client) => - client - .update(schema.botsTable) - .set({ - ...(input.name !== undefined && { name: input.name }), - ...(input.description !== undefined && { - description: input.description, - }), - ...(input.webhookUrl !== undefined && { webhookUrl: input.webhookUrl }), - ...(input.scopes !== undefined && { - scopes: input.scopes ? [...input.scopes] : null, - }), - ...(input.isPublic !== undefined && { isPublic: input.isPublic }), - ...(input.apiTokenHash !== undefined && { - apiTokenHash: input.apiTokenHash, - }), - ...(input.mentionable !== undefined && { - mentionable: input.mentionable, - }), - updatedAt: new Date(), - }) - .where(and(eq(schema.botsTable.id, input.id), isNull(schema.botsTable.deletedAt))) - .returning(), - ), - )(data, tx) + .run( + PG.update(schema.botsTable) + .set({ + ...(data.name !== undefined && { name: data.name }), + ...(data.description !== undefined && { + description: data.description, + }), + ...(data.webhookUrl !== undefined && { webhookUrl: data.webhookUrl }), + ...(data.scopes !== undefined && { + scopes: data.scopes ? [...data.scopes] : null, + }), + ...(data.isPublic !== undefined && { isPublic: data.isPublic }), + ...(data.apiTokenHash !== undefined && { + apiTokenHash: data.apiTokenHash, + }), + ...(data.mentionable !== undefined && { + mentionable: data.mentionable, + }), + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(data.id), $.deletedAt.isNull()]) + .returning(), + ) .pipe(Effect.map((results) => results[0])) // Update token hash - const updateTokenHash = (id: BotId, tokenHash: string, tx?: TxFn) => + const updateTokenHash = (id: BotId, tokenHash: string) => db - .makeQuery((execute, data: { id: BotId; tokenHash: string }) => - execute((client) => - client - .update(schema.botsTable) - .set({ apiTokenHash: data.tokenHash, updatedAt: new Date() }) - .where(and(eq(schema.botsTable.id, data.id), isNull(schema.botsTable.deletedAt))) - .returning(), - ), - )({ id, tokenHash }, tx) + .run( + PG.update(schema.botsTable) + .set({ apiTokenHash: tokenHash, updatedAt: new Date() }) + .where(($) => [$.id.eq(id), $.deletedAt.isNull()]) + .returning(), + ) .pipe(Effect.map((results) => results[0])) // Soft delete a bot - const softDelete = (id: BotId, tx?: TxFn) => - db.makeQuery((execute, botId: BotId) => - execute((client) => - client - .update(schema.botsTable) - .set({ deletedAt: new Date() }) - .where(and(eq(schema.botsTable.id, botId), isNull(schema.botsTable.deletedAt))), - ), - )(id, tx) + const softDelete = (id: BotId) => + db.run( + PG.update(schema.botsTable) + .set({ deletedAt: new Date() }) + .where(($) => [$.id.eq(id), $.deletedAt.isNull()]), + ) // Increment install count - const incrementInstallCount = (id: BotId, tx?: TxFn) => - db.makeQuery((execute, botId: BotId) => - execute((client) => - client - .update(schema.botsTable) - .set({ installCount: sql`${schema.botsTable.installCount} + 1` }) - .where(eq(schema.botsTable.id, botId)), - ), - )(id, tx) + const incrementInstallCount = (id: BotId) => + db.run( + PG.update(schema.botsTable) + .set(($) => ({ installCount: $.installCount.add(1) })) + .where(($) => [$.id.eq(id)]), + ) // Decrement install count - const decrementInstallCount = (id: BotId, tx?: TxFn) => - db.makeQuery((execute, botId: BotId) => - execute((client) => - client - .update(schema.botsTable) - .set({ - installCount: sql`GREATEST(0, ${schema.botsTable.installCount} - 1)`, - }) - .where(eq(schema.botsTable.id, botId)), - ), - )(id, tx) + const decrementInstallCount = (id: BotId) => + db.run( + PG.update(schema.botsTable) + .set(($) => ({ installCount: PG.greatest(PG.lit(0), $.installCount.sub(1)) })) + .where(($) => [$.id.eq(id)]), + ) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/channel-member-repo.ts b/packages/backend-core/src/repositories/channel-member-repo.ts index 2c8821504..96922f4bd 100644 --- a/packages/backend-core/src/repositories/channel-member-repo.ts +++ b/packages/backend-core/src/repositories/channel-member-repo.ts @@ -1,8 +1,8 @@ -import { and, Database, eq, inArray, isNull, Repository, schema, sql, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { ChannelId, OrganizationId, UserId } from "@hazel/schema" import { ChannelMember } from "@hazel/domain/models" -import { Context, Effect, Layer, Option } from "effect" +import { Context, Effect, Layer } from "effect" export class ChannelMemberRepo extends Context.Service()("ChannelMemberRepo", { make: Effect.gen(function* () { @@ -17,83 +17,73 @@ export class ChannelMemberRepo extends Context.Service()("Cha const db = yield* Database.Database // Extended method to find channel member by channel and user - const findByChannelAndUser = (channelId: ChannelId, userId: UserId, tx?: TxFn) => - db - .makeQuery((execute, data: { channelId: ChannelId; userId: UserId }) => - execute((client) => - client - .select() - .from(schema.channelMembersTable) - .where( - and( - eq(schema.channelMembersTable.channelId, data.channelId), - eq(schema.channelMembersTable.userId, data.userId), - isNull(schema.channelMembersTable.deletedAt), - ), - ) - .limit(1), - ), - )({ channelId, userId }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByChannelAndUser = (channelId: ChannelId, userId: UserId) => + db.first( + PG.from(schema.channelMembersTable) + .select() + .where(($) => [$.channelId.eq(channelId), $.userId.eq(userId), $.deletedAt.isNull()]) + .limit(1), + ) // Find existing single DM channel between two users const findExistingSingleDmChannel = ( userId1: UserId, userId2: UserId, organizationId: OrganizationId, - tx?: TxFn, ) => - db - .makeQuery( - (execute, data: { userId1: UserId; userId2: UserId; organizationId: OrganizationId }) => - execute((client) => - client - .selectDistinct({ channel: schema.channelsTable }) - .from(schema.channelMembersTable) - .innerJoin( - schema.channelsTable, - eq(schema.channelMembersTable.channelId, schema.channelsTable.id), - ) - .where( - and( - eq(schema.channelsTable.organizationId, data.organizationId), - eq(schema.channelsTable.type, "single"), - isNull(schema.channelsTable.deletedAt), - isNull(schema.channelMembersTable.deletedAt), - // Channel must have both users as members - inArray(schema.channelMembersTable.userId, [ - data.userId1, - data.userId2, - ]), - ), - ) - .groupBy(schema.channelsTable.id) - // Ensure the channel has exactly 2 members and they are our users - .having( - and( - sql`COUNT(DISTINCT ${schema.channelMembersTable.userId}) = 2`, - sql`COUNT(DISTINCT ${schema.channelMembersTable.userId}) FILTER (WHERE ${schema.channelMembersTable.userId} IN (${data.userId1}, ${data.userId2})) = 2`, - ), - ) - .limit(1), - ), - )({ userId1, userId2, organizationId }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]?.channel))) + db.first( + PG.from(schema.channelsTable) + .innerJoin(schema.channelMembersTable, "cm", (channel, cm) => cm.channelId.eq(channel.id)) + .select(($) => ({ + id: $.id, + name: $.name, + icon: $.icon, + type: $.type, + organizationId: $.organizationId, + parentChannelId: $.parentChannelId, + sectionId: $.sectionId, + createdAt: $.createdAt, + updatedAt: $.updatedAt, + deletedAt: $.deletedAt, + })) + .distinct() + .where(($) => [ + $.organizationId.eq(organizationId), + $.type.eq("single"), + $.deletedAt.isNull(), + $.cm.deletedAt.isNull(), + // Channel must have both users as members + $.cm.userId.in_(userId1, userId2), + ]) + // Group by every selected channel column: `id` is the primary key, so the groups are + // one per channel exactly as grouping by `id` alone was. + .groupBy( + "id", + "name", + "icon", + "type", + "organizationId", + "parentChannelId", + "sectionId", + "createdAt", + "updatedAt", + "deletedAt", + ) + // Ensure the channel has exactly 2 members and they are our users + .having(($) => [ + PG.countDistinct($.cm.userId).eq(2), + PG.sql + .cond`COUNT(DISTINCT ${$.cm.userId}) FILTER (WHERE ${$.cm.userId} IN (${userId1}, ${userId2})) = 2`, + ]) + .limit(1), + ) - const listByChannel = (channelId: ChannelId, tx?: TxFn) => - db.makeQuery((execute, input: ChannelId) => - execute((client) => - client - .select() - .from(schema.channelMembersTable) - .where( - and( - eq(schema.channelMembersTable.channelId, input), - isNull(schema.channelMembersTable.deletedAt), - ), - ), - ), - )(channelId, tx) + const listByChannel = (channelId: ChannelId) => + db.run( + PG.from(schema.channelMembersTable) + .select() + .where(($) => [$.channelId.eq(channelId), $.deletedAt.isNull()]), + ) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/channel-repo.ts b/packages/backend-core/src/repositories/channel-repo.ts index 6e2dc6ef5..f8b7e95da 100644 --- a/packages/backend-core/src/repositories/channel-repo.ts +++ b/packages/backend-core/src/repositories/channel-repo.ts @@ -1,8 +1,8 @@ -import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { OrganizationId } from "@hazel/schema" import { Channel } from "@hazel/domain/models" -import { Context, Effect, Layer, Option } from "effect" +import { Context, Effect, Layer } from "effect" export class ChannelRepo extends Context.Service()("ChannelRepo", { make: Effect.gen(function* () { @@ -16,24 +16,17 @@ export class ChannelRepo extends Context.Service()("ChannelRepo", { ) const db = yield* Database.Database - const findByOrgAndName = (organizationId: OrganizationId, name: string, tx?: TxFn) => - db - .makeQuery((execute, data: { organizationId: OrganizationId; name: string }) => - execute((client) => - client - .select() - .from(schema.channelsTable) - .where( - and( - eq(schema.channelsTable.organizationId, data.organizationId), - eq(schema.channelsTable.name, data.name), - isNull(schema.channelsTable.deletedAt), - ), - ) - .limit(1), - ), - )({ organizationId, name }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByOrgAndName = (organizationId: OrganizationId, name: string) => + db.first( + PG.from(schema.channelsTable) + .select() + .where(($) => [ + $.organizationId.eq(organizationId), + $.name.eq(name), + $.deletedAt.isNull(), + ]) + .limit(1), + ) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/channel-webhook-repo.ts b/packages/backend-core/src/repositories/channel-webhook-repo.ts index d5527941b..ae0a28b9d 100644 --- a/packages/backend-core/src/repositories/channel-webhook-repo.ts +++ b/packages/backend-core/src/repositories/channel-webhook-repo.ts @@ -1,8 +1,8 @@ -import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { ChannelId, ChannelWebhookId, OrganizationId } from "@hazel/schema" import { ChannelWebhook } from "@hazel/domain/models" -import { Context, Effect, Layer, Option } from "effect" +import { Context, Effect, Layer } from "effect" export class ChannelWebhookRepo extends Context.Service()("ChannelWebhookRepo", { make: Effect.gen(function* () { @@ -17,98 +17,63 @@ export class ChannelWebhookRepo extends Context.Service()("C const db = yield* Database.Database // Find all webhooks for a channel - const findByChannel = (channelId: ChannelId, tx?: TxFn) => - db.makeQuery((execute, data: { channelId: ChannelId }) => - execute((client) => - client - .select() - .from(schema.channelWebhooksTable) - .where( - and( - eq(schema.channelWebhooksTable.channelId, data.channelId), - isNull(schema.channelWebhooksTable.deletedAt), - ), - ), - ), - )({ channelId }, tx) + const findByChannel = (channelId: ChannelId) => + db.run( + PG.from(schema.channelWebhooksTable) + .select() + .where(($) => [$.channelId.eq(channelId), $.deletedAt.isNull()]), + ) // Find webhook by token hash (for authentication) - const findByTokenHash = (tokenHash: string, tx?: TxFn) => - db - .makeQuery((execute, data: { tokenHash: string }) => - execute((client) => - client - .select() - .from(schema.channelWebhooksTable) - .where( - and( - eq(schema.channelWebhooksTable.tokenHash, data.tokenHash), - isNull(schema.channelWebhooksTable.deletedAt), - ), - ) - .limit(1), - ), - )({ tokenHash }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByTokenHash = (tokenHash: string) => + db.first( + PG.from(schema.channelWebhooksTable) + .select() + .where(($) => [$.tokenHash.eq(tokenHash), $.deletedAt.isNull()]) + .limit(1), + ) // Update last used timestamp - const updateLastUsed = (id: ChannelWebhookId, tx?: TxFn) => - db.makeQuery((execute, data: { id: ChannelWebhookId }) => - execute((client) => - client - .update(schema.channelWebhooksTable) - .set({ lastUsedAt: new Date(), updatedAt: new Date() }) - .where(eq(schema.channelWebhooksTable.id, data.id)) - .returning(), - ), - )({ id }, tx) + const updateLastUsed = (id: ChannelWebhookId) => + db.run( + PG.update(schema.channelWebhooksTable) + .set({ lastUsedAt: new Date(), updatedAt: new Date() }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) // Update token hash (for token regeneration) - const updateToken = (id: ChannelWebhookId, tokenHash: string, tokenSuffix: string, tx?: TxFn) => - db.makeQuery((execute, data: { id: ChannelWebhookId; tokenHash: string; tokenSuffix: string }) => - execute((client) => - client - .update(schema.channelWebhooksTable) - .set({ - tokenHash: data.tokenHash, - tokenSuffix: data.tokenSuffix, - updatedAt: new Date(), - }) - .where(eq(schema.channelWebhooksTable.id, data.id)) - .returning(), - ), - )({ id, tokenHash, tokenSuffix }, tx) + const updateToken = (id: ChannelWebhookId, tokenHash: string, tokenSuffix: string) => + db.run( + PG.update(schema.channelWebhooksTable) + .set({ + tokenHash, + tokenSuffix, + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) // Soft delete webhook - const softDelete = (id: ChannelWebhookId, tx?: TxFn) => - db.makeQuery((execute, data: { id: ChannelWebhookId }) => - execute((client) => - client - .update(schema.channelWebhooksTable) - .set({ - deletedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(schema.channelWebhooksTable.id, data.id)) - .returning(), - ), - )({ id }, tx) + const softDelete = (id: ChannelWebhookId) => + db.run( + PG.update(schema.channelWebhooksTable) + .set({ + deletedAt: new Date(), + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) // Find all webhooks for an organization - const findByOrganization = (organizationId: OrganizationId, tx?: TxFn) => - db.makeQuery((execute, data: { organizationId: OrganizationId }) => - execute((client) => - client - .select() - .from(schema.channelWebhooksTable) - .where( - and( - eq(schema.channelWebhooksTable.organizationId, data.organizationId), - isNull(schema.channelWebhooksTable.deletedAt), - ), - ), - ), - )({ organizationId }, tx) + const findByOrganization = (organizationId: OrganizationId) => + db.run( + PG.from(schema.channelWebhooksTable) + .select() + .where(($) => [$.organizationId.eq(organizationId), $.deletedAt.isNull()]), + ) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/chat-sync-channel-link-repo.ts b/packages/backend-core/src/repositories/chat-sync-channel-link-repo.ts index edbb45cb9..d4c4d454f 100644 --- a/packages/backend-core/src/repositories/chat-sync-channel-link-repo.ts +++ b/packages/backend-core/src/repositories/chat-sync-channel-link-repo.ts @@ -1,4 +1,4 @@ -import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import { ChatSyncChannelLink } from "@hazel/domain/models" import type { ChannelId, ExternalChannelId, SyncChannelLinkId, SyncConnectionId } from "@hazel/schema" @@ -28,253 +28,140 @@ export class ChatSyncChannelLinkRepo extends Context.Service - const findById = (id: SyncChannelLinkId, tx?: TxFn) => + const findById = (id: SyncChannelLinkId) => db - .makeQuery((execute, data: { id: SyncChannelLinkId }) => - execute((client) => - client - .select() - .from(schema.chatSyncChannelLinksTable) - .where( - and( - eq(schema.chatSyncChannelLinksTable.id, data.id), - isNull(schema.chatSyncChannelLinksTable.deletedAt), - ), - ) - .limit(1), - ), - )({ id }, tx) - .pipe( - Effect.map((results) => - Option.fromNullishOr(results[0]).pipe(decodeChannelLinkOption), - ), + .first( + PG.from(schema.chatSyncChannelLinksTable) + .select() + .where(($) => [$.id.eq(id), $.deletedAt.isNull()]) + .limit(1), ) + .pipe(Effect.map(decodeChannelLinkOption)) - const findBySyncConnection = (syncConnectionId: SyncConnectionId, tx?: TxFn) => + const findBySyncConnection = (syncConnectionId: SyncConnectionId) => db - .makeQuery((execute, data: { syncConnectionId: SyncConnectionId }) => - execute((client) => - client - .select() - .from(schema.chatSyncChannelLinksTable) - .where( - and( - eq( - schema.chatSyncChannelLinksTable.syncConnectionId, - data.syncConnectionId, - ), - isNull(schema.chatSyncChannelLinksTable.deletedAt), - ), - ), - ), - )({ syncConnectionId }, tx) + .run( + PG.from(schema.chatSyncChannelLinksTable) + .select() + .where(($) => [$.syncConnectionId.eq(syncConnectionId), $.deletedAt.isNull()]), + ) .pipe(Effect.map(decodeChannelLinkRows)) - const findActiveBySyncConnection = (syncConnectionId: SyncConnectionId, tx?: TxFn) => + const findActiveBySyncConnection = (syncConnectionId: SyncConnectionId) => db - .makeQuery((execute, data: { syncConnectionId: SyncConnectionId }) => - execute((client) => - client - .select() - .from(schema.chatSyncChannelLinksTable) - .where( - and( - eq( - schema.chatSyncChannelLinksTable.syncConnectionId, - data.syncConnectionId, - ), - eq(schema.chatSyncChannelLinksTable.isActive, true), - isNull(schema.chatSyncChannelLinksTable.deletedAt), - ), - ), - ), - )({ syncConnectionId }, tx) + .run( + PG.from(schema.chatSyncChannelLinksTable) + .select() + .where(($) => [ + $.syncConnectionId.eq(syncConnectionId), + $.isActive.eq(true), + $.deletedAt.isNull(), + ]), + ) .pipe(Effect.map(decodeChannelLinkRows)) - const findByHazelChannel = ( - syncConnectionId: SyncConnectionId, - hazelChannelId: ChannelId, - tx?: TxFn, - ) => + const findByHazelChannel = (syncConnectionId: SyncConnectionId, hazelChannelId: ChannelId) => db - .makeQuery( - ( - execute, - data: { - syncConnectionId: SyncConnectionId - hazelChannelId: ChannelId - }, - ) => - execute((client) => - client - .select() - .from(schema.chatSyncChannelLinksTable) - .where( - and( - eq( - schema.chatSyncChannelLinksTable.syncConnectionId, - data.syncConnectionId, - ), - eq( - schema.chatSyncChannelLinksTable.hazelChannelId, - data.hazelChannelId, - ), - isNull(schema.chatSyncChannelLinksTable.deletedAt), - ), - ) - .limit(1), - ), - )({ syncConnectionId, hazelChannelId }, tx) - .pipe( - Effect.map((results) => - Option.fromNullishOr(results[0]).pipe(decodeChannelLinkOption), - ), + .first( + PG.from(schema.chatSyncChannelLinksTable) + .select() + .where(($) => [ + $.syncConnectionId.eq(syncConnectionId), + $.hazelChannelId.eq(hazelChannelId), + $.deletedAt.isNull(), + ]) + .limit(1), ) + .pipe(Effect.map(decodeChannelLinkOption)) const findByExternalChannel = ( syncConnectionId: SyncConnectionId, externalChannelId: ExternalChannelId, - tx?: TxFn, ) => db - .makeQuery( - ( - execute, - data: { - syncConnectionId: SyncConnectionId - externalChannelId: ExternalChannelId - }, - ) => - execute((client) => - client - .select() - .from(schema.chatSyncChannelLinksTable) - .where( - and( - eq( - schema.chatSyncChannelLinksTable.syncConnectionId, - data.syncConnectionId, - ), - eq( - schema.chatSyncChannelLinksTable.externalChannelId, - data.externalChannelId, - ), - isNull(schema.chatSyncChannelLinksTable.deletedAt), - ), - ) - .limit(1), - ), - )({ syncConnectionId, externalChannelId }, tx) - .pipe( - Effect.map((results) => - Option.fromNullishOr(results[0]).pipe(decodeChannelLinkOption), - ), + .first( + PG.from(schema.chatSyncChannelLinksTable) + .select() + .where(($) => [ + $.syncConnectionId.eq(syncConnectionId), + $.externalChannelId.eq(externalChannelId), + $.deletedAt.isNull(), + ]) + .limit(1), ) + .pipe(Effect.map(decodeChannelLinkOption)) - const findActiveByExternalChannel = (externalChannelId: ExternalChannelId, tx?: TxFn) => + const findActiveByExternalChannel = (externalChannelId: ExternalChannelId) => db - .makeQuery((execute, data: { externalChannelId: ExternalChannelId }) => - execute((client) => - client - .select() - .from(schema.chatSyncChannelLinksTable) - .where( - and( - eq( - schema.chatSyncChannelLinksTable.externalChannelId, - data.externalChannelId, - ), - eq(schema.chatSyncChannelLinksTable.isActive, true), - isNull(schema.chatSyncChannelLinksTable.deletedAt), - ), - ), - ), - )({ externalChannelId }, tx) + .run( + PG.from(schema.chatSyncChannelLinksTable) + .select() + .where(($) => [ + $.externalChannelId.eq(externalChannelId), + $.isActive.eq(true), + $.deletedAt.isNull(), + ]), + ) .pipe(Effect.map(decodeChannelLinkRows)) - const setActive = (id: SyncChannelLinkId, isActive: boolean, tx?: TxFn) => - db.makeQuery((execute, data: { id: SyncChannelLinkId; isActive: boolean }) => - execute((client) => - client - .update(schema.chatSyncChannelLinksTable) - .set({ - isActive: data.isActive, - updatedAt: new Date(), - }) - .where(eq(schema.chatSyncChannelLinksTable.id, data.id)) - .returning(), - ), - )({ id, isActive }, tx) + const setActive = (id: SyncChannelLinkId, isActive: boolean) => + db.run( + PG.update(schema.chatSyncChannelLinksTable) + .set({ + isActive, + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) - const updateLastSyncedAt = (id: SyncChannelLinkId, tx?: TxFn) => - db.makeQuery((execute, data: { id: SyncChannelLinkId }) => - execute((client) => - client - .update(schema.chatSyncChannelLinksTable) - .set({ - lastSyncedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(schema.chatSyncChannelLinksTable.id, data.id)) - .returning(), - ), - )({ id }, tx) + const updateLastSyncedAt = (id: SyncChannelLinkId) => + db.run( + PG.update(schema.chatSyncChannelLinksTable) + .set({ + lastSyncedAt: new Date(), + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) const updateDirection = ( id: SyncChannelLinkId, direction: ChatSyncChannelLink.ChatSyncDirection, - tx?: TxFn, ) => - db.makeQuery( - ( - execute, - data: { id: SyncChannelLinkId; direction: ChatSyncChannelLink.ChatSyncDirection }, - ) => - execute((client) => - client - .update(schema.chatSyncChannelLinksTable) - .set({ - direction: data.direction, - updatedAt: new Date(), - }) - .where(eq(schema.chatSyncChannelLinksTable.id, data.id)) - .returning(), - ), - )({ id, direction }, tx) + db.run( + PG.update(schema.chatSyncChannelLinksTable) + .set({ + direction, + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) - const softDelete = (id: SyncChannelLinkId, tx?: TxFn) => - db.makeQuery((execute, data: { id: SyncChannelLinkId }) => - execute((client) => - client - .update(schema.chatSyncChannelLinksTable) - .set({ - deletedAt: new Date(), - isActive: false, - updatedAt: new Date(), - }) - .where(eq(schema.chatSyncChannelLinksTable.id, data.id)) - .returning(), - ), - )({ id }, tx) + const softDelete = (id: SyncChannelLinkId) => + db.run( + PG.update(schema.chatSyncChannelLinksTable) + .set({ + deletedAt: new Date(), + isActive: false, + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) - const updateSettings = ( - id: SyncChannelLinkId, - settings: Record | null, - tx?: TxFn, - ) => - db.makeQuery( - (execute, data: { id: SyncChannelLinkId; settings: Record | null }) => - execute((client) => - client - .update(schema.chatSyncChannelLinksTable) - .set({ - settings: data.settings, - updatedAt: new Date(), - }) - .where(eq(schema.chatSyncChannelLinksTable.id, data.id)) - .returning(), - ), - )({ id, settings }, tx) + const updateSettings = (id: SyncChannelLinkId, settings: Record | null) => + db.run( + PG.update(schema.chatSyncChannelLinksTable) + .set({ + settings, + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/chat-sync-connection-repo.ts b/packages/backend-core/src/repositories/chat-sync-connection-repo.ts index e448072cb..3f8f1c60d 100644 --- a/packages/backend-core/src/repositories/chat-sync-connection-repo.ts +++ b/packages/backend-core/src/repositories/chat-sync-connection-repo.ts @@ -1,8 +1,8 @@ -import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import { ChatSyncConnection } from "@hazel/domain/models" import type { IntegrationConnectionId, OrganizationId, SyncConnectionId } from "@hazel/schema" -import { Context, Effect, Layer, Option } from "effect" +import { Context, Effect, Layer } from "effect" export class ChatSyncConnectionRepo extends Context.Service()( "ChatSyncConnectionRepo", @@ -18,156 +18,86 @@ export class ChatSyncConnectionRepo extends Context.Service - db.makeQuery((execute, data: { organizationId: OrganizationId }) => - execute((client) => - client - .select() - .from(schema.chatSyncConnectionsTable) - .where( - and( - eq(schema.chatSyncConnectionsTable.organizationId, data.organizationId), - isNull(schema.chatSyncConnectionsTable.deletedAt), - ), - ), - ), - )({ organizationId }, tx) + const findByOrganization = (organizationId: OrganizationId) => + db.run( + PG.from(schema.chatSyncConnectionsTable) + .select() + .where(($) => [$.organizationId.eq(organizationId), $.deletedAt.isNull()]), + ) const findByProviderAndWorkspace = ( organizationId: OrganizationId, provider: string, externalWorkspaceId: string, - tx?: TxFn, ) => - db - .makeQuery( - ( - execute, - data: { - organizationId: OrganizationId - provider: string - externalWorkspaceId: string - }, - ) => - execute((client) => - client - .select() - .from(schema.chatSyncConnectionsTable) - .where( - and( - eq( - schema.chatSyncConnectionsTable.organizationId, - data.organizationId, - ), - eq(schema.chatSyncConnectionsTable.provider, data.provider), - eq( - schema.chatSyncConnectionsTable.externalWorkspaceId, - data.externalWorkspaceId, - ), - isNull(schema.chatSyncConnectionsTable.deletedAt), - ), - ) - .limit(1), - ), - )({ organizationId, provider, externalWorkspaceId }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + db.first( + PG.from(schema.chatSyncConnectionsTable) + .select() + .where(($) => [ + $.organizationId.eq(organizationId), + $.provider.eq(provider), + $.externalWorkspaceId.eq(externalWorkspaceId), + $.deletedAt.isNull(), + ]) + .limit(1), + ) - const findActiveByProvider = (provider: string, tx?: TxFn) => - db.makeQuery((execute, data: { provider: string }) => - execute((client) => - client - .select() - .from(schema.chatSyncConnectionsTable) - .where( - and( - eq(schema.chatSyncConnectionsTable.provider, data.provider), - eq(schema.chatSyncConnectionsTable.status, "active"), - isNull(schema.chatSyncConnectionsTable.deletedAt), - ), - ), - ), - )({ provider }, tx) + const findActiveByProvider = (provider: string) => + db.run( + PG.from(schema.chatSyncConnectionsTable) + .select() + .where(($) => [$.provider.eq(provider), $.status.eq("active"), $.deletedAt.isNull()]), + ) - const findByIntegrationConnectionId = ( - integrationConnectionId: IntegrationConnectionId, - tx?: TxFn, - ) => - db - .makeQuery((execute, data: { integrationConnectionId: IntegrationConnectionId }) => - execute((client) => - client - .select() - .from(schema.chatSyncConnectionsTable) - .where( - and( - eq( - schema.chatSyncConnectionsTable.integrationConnectionId, - data.integrationConnectionId, - ), - isNull(schema.chatSyncConnectionsTable.deletedAt), - ), - ) - .limit(1), - ), - )({ integrationConnectionId }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByIntegrationConnectionId = (integrationConnectionId: IntegrationConnectionId) => + db.first( + PG.from(schema.chatSyncConnectionsTable) + .select() + .where(($) => [ + $.integrationConnectionId.eq(integrationConnectionId), + $.deletedAt.isNull(), + ]) + .limit(1), + ) const updateStatus = ( id: SyncConnectionId, status: ChatSyncConnection.ChatSyncConnectionStatus, errorMessage?: string, - tx?: TxFn, ) => - db.makeQuery( - ( - execute, - data: { - id: SyncConnectionId - status: ChatSyncConnection.ChatSyncConnectionStatus - errorMessage?: string - }, - ) => - execute((client) => - client - .update(schema.chatSyncConnectionsTable) - .set({ - status: data.status, - errorMessage: data.errorMessage ?? null, - updatedAt: new Date(), - }) - .where(eq(schema.chatSyncConnectionsTable.id, data.id)) - .returning(), - ), - )({ id, status, errorMessage }, tx) + db.run( + PG.update(schema.chatSyncConnectionsTable) + .set({ + status, + errorMessage: errorMessage ?? null, + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) - const updateLastSyncedAt = (id: SyncConnectionId, tx?: TxFn) => - db.makeQuery((execute, data: { id: SyncConnectionId }) => - execute((client) => - client - .update(schema.chatSyncConnectionsTable) - .set({ - lastSyncedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(schema.chatSyncConnectionsTable.id, data.id)) - .returning(), - ), - )({ id }, tx) + const updateLastSyncedAt = (id: SyncConnectionId) => + db.run( + PG.update(schema.chatSyncConnectionsTable) + .set({ + lastSyncedAt: new Date(), + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) - const softDelete = (id: SyncConnectionId, tx?: TxFn) => - db.makeQuery((execute, data: { id: SyncConnectionId }) => - execute((client) => - client - .update(schema.chatSyncConnectionsTable) - .set({ - deletedAt: new Date(), - status: "disabled", - updatedAt: new Date(), - }) - .where(eq(schema.chatSyncConnectionsTable.id, data.id)) - .returning(), - ), - )({ id }, tx) + const softDelete = (id: SyncConnectionId) => + db.run( + PG.update(schema.chatSyncConnectionsTable) + .set({ + deletedAt: new Date(), + status: "disabled", + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/chat-sync-event-receipt-repo.ts b/packages/backend-core/src/repositories/chat-sync-event-receipt-repo.ts index bbce1dab5..c0f8c0b2e 100644 --- a/packages/backend-core/src/repositories/chat-sync-event-receipt-repo.ts +++ b/packages/backend-core/src/repositories/chat-sync-event-receipt-repo.ts @@ -1,8 +1,8 @@ -import { and, Database, eq, gte, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import { ChatSyncEventReceipt } from "@hazel/domain/models" import type { SyncChannelLinkId, SyncConnectionId, SyncEventReceiptId } from "@hazel/schema" -import { Context, Effect, Layer, Option } from "effect" +import { Context, Effect, Layer } from "effect" export class ChatSyncEventReceiptRepo extends Context.Service()( "ChatSyncEventReceiptRepo", @@ -22,192 +22,104 @@ export class ChatSyncEventReceiptRepo extends Context.Service - db - .makeQuery( - ( - execute, - data: { - syncConnectionId: SyncConnectionId - source: ChatSyncEventReceipt.ChatSyncReceiptSource - dedupeKey: string - }, - ) => - execute((client) => - client - .select() - .from(schema.chatSyncEventReceiptsTable) - .where( - and( - eq( - schema.chatSyncEventReceiptsTable.syncConnectionId, - data.syncConnectionId, - ), - eq(schema.chatSyncEventReceiptsTable.source, data.source), - eq(schema.chatSyncEventReceiptsTable.dedupeKey, data.dedupeKey), - ), - ) - .limit(1), - ), - )({ syncConnectionId, source, dedupeKey }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + db.first( + PG.from(schema.chatSyncEventReceiptsTable) + .select() + .where(($) => [ + $.syncConnectionId.eq(syncConnectionId), + $.source.eq(source), + $.dedupeKey.eq(dedupeKey), + ]) + .limit(1), + ) - const claimByDedupeKey = ( - params: { - syncConnectionId: SyncConnectionId - channelLinkId?: SyncChannelLinkId | null - source: ChatSyncEventReceipt.ChatSyncReceiptSource - dedupeKey: string - }, - tx?: TxFn, - ) => + const claimByDedupeKey = (params: { + syncConnectionId: SyncConnectionId + channelLinkId?: SyncChannelLinkId | null + source: ChatSyncEventReceipt.ChatSyncReceiptSource + dedupeKey: string + }) => db - .makeQuery( - ( - execute, - data: { - syncConnectionId: SyncConnectionId - channelLinkId?: SyncChannelLinkId | null - source: ChatSyncEventReceipt.ChatSyncReceiptSource - dedupeKey: string - }, - ) => - execute((client) => - client - .insert(schema.chatSyncEventReceiptsTable) - .values({ - syncConnectionId: data.syncConnectionId, - channelLinkId: data.channelLinkId ?? null, - source: data.source, - externalEventId: null, - dedupeKey: data.dedupeKey, - payloadHash: null, - status: "processed", - errorMessage: null, - }) - .onConflictDoNothing({ - target: [ - schema.chatSyncEventReceiptsTable.syncConnectionId, - schema.chatSyncEventReceiptsTable.source, - schema.chatSyncEventReceiptsTable.dedupeKey, - ], - }) - .returning({ id: schema.chatSyncEventReceiptsTable.id }), - ), - )(params, tx) + .run( + PG.insertInto(schema.chatSyncEventReceiptsTable) + .values({ + syncConnectionId: params.syncConnectionId, + channelLinkId: params.channelLinkId ?? null, + source: params.source, + externalEventId: null, + dedupeKey: params.dedupeKey, + payloadHash: null, + status: "processed", + errorMessage: null, + }) + .onConflictDoNothing({ target: ["syncConnectionId", "source", "dedupeKey"] }) + .returning("id"), + ) .pipe(Effect.map((results) => results.length > 0)) - const updateByDedupeKey = ( - params: { - syncConnectionId: SyncConnectionId - source: ChatSyncEventReceipt.ChatSyncReceiptSource - dedupeKey: string + const updateByDedupeKey = (params: { + syncConnectionId: SyncConnectionId + source: ChatSyncEventReceipt.ChatSyncReceiptSource + dedupeKey: string + channelLinkId?: SyncChannelLinkId | null + externalEventId?: string | null + payloadHash?: string | null + status?: ChatSyncEventReceipt.ChatSyncReceiptStatus + errorMessage?: string | null + }) => { + const set: { channelLinkId?: SyncChannelLinkId | null externalEventId?: string | null - payloadHash?: string | null - status?: ChatSyncEventReceipt.ChatSyncReceiptStatus - errorMessage?: string | null - }, - tx?: TxFn, - ) => - db.makeQuery( - ( - execute, - data: { - syncConnectionId: SyncConnectionId - source: ChatSyncEventReceipt.ChatSyncReceiptSource - dedupeKey: string - channelLinkId?: SyncChannelLinkId | null - externalEventId?: string | null - payloadHash?: string | null - status?: ChatSyncEventReceipt.ChatSyncReceiptStatus - errorMessage?: string | null - }, - ) => - execute((client) => { - const set: { - channelLinkId?: SyncChannelLinkId | null - externalEventId?: string | null - payloadHash: string | null - status: ChatSyncEventReceipt.ChatSyncReceiptStatus - errorMessage: string | null - processedAt: Date - } = { - payloadHash: data.payloadHash ?? null, - status: data.status ?? "processed", - errorMessage: data.errorMessage ?? null, - processedAt: new Date(), - } - if (data.channelLinkId !== undefined) { - set.channelLinkId = data.channelLinkId - } - if (data.externalEventId !== undefined) { - set.externalEventId = data.externalEventId - } + payloadHash: string | null + status: ChatSyncEventReceipt.ChatSyncReceiptStatus + errorMessage: string | null + processedAt: Date + } = { + payloadHash: params.payloadHash ?? null, + status: params.status ?? "processed", + errorMessage: params.errorMessage ?? null, + processedAt: new Date(), + } + if (params.channelLinkId !== undefined) { + set.channelLinkId = params.channelLinkId + } + if (params.externalEventId !== undefined) { + set.externalEventId = params.externalEventId + } - return client - .update(schema.chatSyncEventReceiptsTable) - .set(set) - .where( - and( - eq( - schema.chatSyncEventReceiptsTable.syncConnectionId, - data.syncConnectionId, - ), - eq(schema.chatSyncEventReceiptsTable.source, data.source), - eq(schema.chatSyncEventReceiptsTable.dedupeKey, data.dedupeKey), - ), - ) - .returning() - }), - )(params, tx) + return db.run( + PG.update(schema.chatSyncEventReceiptsTable) + .set(set) + .where(($) => [ + $.syncConnectionId.eq(params.syncConnectionId), + $.source.eq(params.source), + $.dedupeKey.eq(params.dedupeKey), + ]) + .returning(), + ) + } - const findRecentByConnection = ( - syncConnectionId: SyncConnectionId, - processedAfter: Date, - tx?: TxFn, - ) => - db.makeQuery( - ( - execute, - data: { - syncConnectionId: SyncConnectionId - processedAfter: Date - }, - ) => - execute((client) => - client - .select() - .from(schema.chatSyncEventReceiptsTable) - .where( - and( - eq( - schema.chatSyncEventReceiptsTable.syncConnectionId, - data.syncConnectionId, - ), - gte( - schema.chatSyncEventReceiptsTable.processedAt, - data.processedAfter, - ), - ), - ), - ), - )({ syncConnectionId, processedAfter }, tx) + const findRecentByConnection = (syncConnectionId: SyncConnectionId, processedAfter: Date) => + db.run( + PG.from(schema.chatSyncEventReceiptsTable) + .select() + .where(($) => [ + $.syncConnectionId.eq(syncConnectionId), + $.processedAt.gte(processedAfter), + ]), + ) - const markFailed = (id: SyncEventReceiptId, errorMessage: string, tx?: TxFn) => - db.makeQuery((execute, data: { id: SyncEventReceiptId; errorMessage: string }) => - execute((client) => - client - .update(schema.chatSyncEventReceiptsTable) - .set({ - status: "failed", - errorMessage: data.errorMessage, - }) - .where(eq(schema.chatSyncEventReceiptsTable.id, data.id)) - .returning(), - ), - )({ id, errorMessage }, tx) + const markFailed = (id: SyncEventReceiptId, errorMessage: string) => + db.run( + PG.update(schema.chatSyncEventReceiptsTable) + .set({ + status: "failed", + errorMessage, + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/chat-sync-message-link-repo.ts b/packages/backend-core/src/repositories/chat-sync-message-link-repo.ts index 4d97756ad..3da6caf72 100644 --- a/packages/backend-core/src/repositories/chat-sync-message-link-repo.ts +++ b/packages/backend-core/src/repositories/chat-sync-message-link-repo.ts @@ -1,4 +1,4 @@ -import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import { ChatSyncMessageLink } from "@hazel/domain/models" import type { @@ -34,172 +34,83 @@ export class ChatSyncMessageLinkRepo extends Context.Service - const findByChannelLink = (channelLinkId: SyncChannelLinkId, tx?: TxFn) => + const findByChannelLink = (channelLinkId: SyncChannelLinkId) => db - .makeQuery((execute, data: { channelLinkId: SyncChannelLinkId }) => - execute((client) => - client - .select() - .from(schema.chatSyncMessageLinksTable) - .where( - and( - eq( - schema.chatSyncMessageLinksTable.channelLinkId, - data.channelLinkId, - ), - isNull(schema.chatSyncMessageLinksTable.deletedAt), - ), - ), - ), - )({ channelLinkId }, tx) + .run( + PG.from(schema.chatSyncMessageLinksTable) + .select() + .where(($) => [$.channelLinkId.eq(channelLinkId), $.deletedAt.isNull()]), + ) .pipe(Effect.map(decodeMessageLinkRows)) - const findByHazelMessage = ( - channelLinkId: SyncChannelLinkId, - hazelMessageId: MessageId, - tx?: TxFn, - ) => + const findByHazelMessage = (channelLinkId: SyncChannelLinkId, hazelMessageId: MessageId) => db - .makeQuery( - ( - execute, - data: { - channelLinkId: SyncChannelLinkId - hazelMessageId: MessageId - }, - ) => - execute((client) => - client - .select() - .from(schema.chatSyncMessageLinksTable) - .where( - and( - eq( - schema.chatSyncMessageLinksTable.channelLinkId, - data.channelLinkId, - ), - eq( - schema.chatSyncMessageLinksTable.hazelMessageId, - data.hazelMessageId, - ), - isNull(schema.chatSyncMessageLinksTable.deletedAt), - ), - ) - .limit(1), - ), - )({ channelLinkId, hazelMessageId }, tx) - .pipe( - Effect.map((results) => - Option.fromNullishOr(results[0]).pipe(decodeMessageLinkOption), - ), + .first( + PG.from(schema.chatSyncMessageLinksTable) + .select() + .where(($) => [ + $.channelLinkId.eq(channelLinkId), + $.hazelMessageId.eq(hazelMessageId), + $.deletedAt.isNull(), + ]) + .limit(1), ) + .pipe(Effect.map(decodeMessageLinkOption)) const findByExternalMessage = ( channelLinkId: SyncChannelLinkId, externalMessageId: ExternalMessageId, - tx?: TxFn, ) => db - .makeQuery( - ( - execute, - data: { - channelLinkId: SyncChannelLinkId - externalMessageId: ExternalMessageId - }, - ) => - execute((client) => - client - .select() - .from(schema.chatSyncMessageLinksTable) - .where( - and( - eq( - schema.chatSyncMessageLinksTable.channelLinkId, - data.channelLinkId, - ), - eq( - schema.chatSyncMessageLinksTable.externalMessageId, - data.externalMessageId, - ), - isNull(schema.chatSyncMessageLinksTable.deletedAt), - ), - ) - .limit(1), - ), - )({ channelLinkId, externalMessageId }, tx) - .pipe( - Effect.map((results) => - Option.fromNullishOr(results[0]).pipe(decodeMessageLinkOption), - ), + .first( + PG.from(schema.chatSyncMessageLinksTable) + .select() + .where(($) => [ + $.channelLinkId.eq(channelLinkId), + $.externalMessageId.eq(externalMessageId), + $.deletedAt.isNull(), + ]) + .limit(1), ) + .pipe(Effect.map(decodeMessageLinkOption)) const findByRootHazelMessage = ( channelLinkId: SyncChannelLinkId, rootHazelMessageId: MessageId, - tx?: TxFn, ) => db - .makeQuery( - ( - execute, - data: { - channelLinkId: SyncChannelLinkId - rootHazelMessageId: MessageId - }, - ) => - execute((client) => - client - .select() - .from(schema.chatSyncMessageLinksTable) - .where( - and( - eq( - schema.chatSyncMessageLinksTable.channelLinkId, - data.channelLinkId, - ), - eq( - schema.chatSyncMessageLinksTable.rootHazelMessageId, - data.rootHazelMessageId, - ), - isNull(schema.chatSyncMessageLinksTable.deletedAt), - ), - ), - ), - )({ channelLinkId, rootHazelMessageId }, tx) - .pipe( - Effect.map((results) => - Option.fromNullishOr(results[0]).pipe(decodeMessageLinkOption), - ), + .first( + PG.from(schema.chatSyncMessageLinksTable) + .select() + .where(($) => [ + $.channelLinkId.eq(channelLinkId), + $.rootHazelMessageId.eq(rootHazelMessageId), + $.deletedAt.isNull(), + ]), ) + .pipe(Effect.map(decodeMessageLinkOption)) - const updateLastSyncedAt = (id: SyncMessageLinkId, tx?: TxFn) => - db.makeQuery((execute, data: { id: SyncMessageLinkId }) => - execute((client) => - client - .update(schema.chatSyncMessageLinksTable) - .set({ - lastSyncedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(schema.chatSyncMessageLinksTable.id, data.id)) - .returning(), - ), - )({ id }, tx) + const updateLastSyncedAt = (id: SyncMessageLinkId) => + db.run( + PG.update(schema.chatSyncMessageLinksTable) + .set({ + lastSyncedAt: new Date(), + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) - const softDelete = (id: SyncMessageLinkId, tx?: TxFn) => - db.makeQuery((execute, data: { id: SyncMessageLinkId }) => - execute((client) => - client - .update(schema.chatSyncMessageLinksTable) - .set({ - deletedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(schema.chatSyncMessageLinksTable.id, data.id)) - .returning(), - ), - )({ id }, tx) + const softDelete = (id: SyncMessageLinkId) => + db.run( + PG.update(schema.chatSyncMessageLinksTable) + .set({ + deletedAt: new Date(), + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/connect-conversation-channel-repo.ts b/packages/backend-core/src/repositories/connect-conversation-channel-repo.ts index a1f998c1d..1867570f0 100644 --- a/packages/backend-core/src/repositories/connect-conversation-channel-repo.ts +++ b/packages/backend-core/src/repositories/connect-conversation-channel-repo.ts @@ -1,7 +1,7 @@ -import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { ChannelId, ConnectConversationId, OrganizationId } from "@hazel/schema" import { ConnectConversationChannel } from "@hazel/domain/models" -import { Context, Effect, Layer, Option } from "effect" +import { Context, Effect, Layer } from "effect" export class ConnectConversationChannelRepo extends Context.Service()( "ConnectConversationChannelRepo", @@ -17,71 +17,35 @@ export class ConnectConversationChannelRepo extends Context.Service - db - .makeQuery((execute, input: ChannelId) => - execute((client) => - client - .select() - .from(schema.connectConversationChannelsTable) - .where( - and( - eq(schema.connectConversationChannelsTable.channelId, input), - isNull(schema.connectConversationChannelsTable.deletedAt), - ), - ) - .limit(1), - ), - )(channelId, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByChannelId = (channelId: ChannelId) => + db.first( + PG.from(schema.connectConversationChannelsTable) + .select() + .where(($) => [$.channelId.eq(channelId), $.deletedAt.isNull()]) + .limit(1), + ) - const findByConversationId = (conversationId: ConnectConversationId, tx?: TxFn) => - db.makeQuery((execute, input: ConnectConversationId) => - execute((client) => - client - .select() - .from(schema.connectConversationChannelsTable) - .where( - and( - eq(schema.connectConversationChannelsTable.conversationId, input), - isNull(schema.connectConversationChannelsTable.deletedAt), - ), - ), - ), - )(conversationId, tx) + const findByConversationId = (conversationId: ConnectConversationId) => + db.run( + PG.from(schema.connectConversationChannelsTable) + .select() + .where(($) => [$.conversationId.eq(conversationId), $.deletedAt.isNull()]), + ) const findByConversationAndOrganization = ( conversationId: ConnectConversationId, organizationId: OrganizationId, - tx?: TxFn, ) => - db - .makeQuery( - ( - execute, - input: { conversationId: ConnectConversationId; organizationId: OrganizationId }, - ) => - execute((client) => - client - .select() - .from(schema.connectConversationChannelsTable) - .where( - and( - eq( - schema.connectConversationChannelsTable.conversationId, - input.conversationId, - ), - eq( - schema.connectConversationChannelsTable.organizationId, - input.organizationId, - ), - isNull(schema.connectConversationChannelsTable.deletedAt), - ), - ) - .limit(1), - ), - )({ conversationId, organizationId }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + db.first( + PG.from(schema.connectConversationChannelsTable) + .select() + .where(($) => [ + $.conversationId.eq(conversationId), + $.organizationId.eq(organizationId), + $.deletedAt.isNull(), + ]) + .limit(1), + ) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/connect-conversation-repo.ts b/packages/backend-core/src/repositories/connect-conversation-repo.ts index f031912af..2af5038da 100644 --- a/packages/backend-core/src/repositories/connect-conversation-repo.ts +++ b/packages/backend-core/src/repositories/connect-conversation-repo.ts @@ -1,7 +1,7 @@ -import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { ChannelId } from "@hazel/schema" import { ConnectConversation } from "@hazel/domain/models" -import { Context, Effect, Layer, Option } from "effect" +import { Context, Effect, Layer } from "effect" export class ConnectConversationRepo extends Context.Service()( "ConnectConversationRepo", @@ -17,23 +17,13 @@ export class ConnectConversationRepo extends Context.Service - db - .makeQuery((execute, channelId: ChannelId) => - execute((client) => - client - .select() - .from(schema.connectConversationsTable) - .where( - and( - eq(schema.connectConversationsTable.hostChannelId, channelId), - isNull(schema.connectConversationsTable.deletedAt), - ), - ) - .limit(1), - ), - )(hostChannelId, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByHostChannel = (hostChannelId: ChannelId) => + db.first( + PG.from(schema.connectConversationsTable) + .select() + .where(($) => [$.hostChannelId.eq(hostChannelId), $.deletedAt.isNull()]) + .limit(1), + ) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/connect-invite-repo.ts b/packages/backend-core/src/repositories/connect-invite-repo.ts index 14ec00357..eb11a9759 100644 --- a/packages/backend-core/src/repositories/connect-invite-repo.ts +++ b/packages/backend-core/src/repositories/connect-invite-repo.ts @@ -1,7 +1,7 @@ -import { and, Database, eq, isNull, Repository, or, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { ConnectInviteId, OrganizationId } from "@hazel/schema" import { ConnectInvite } from "@hazel/domain/models" -import { Context, Effect, Layer, Option } from "effect" +import { Context, Effect, Layer } from "effect" export class ConnectInviteRepo extends Context.Service()("ConnectInviteRepo", { make: Effect.gen(function* () { @@ -15,72 +15,38 @@ export class ConnectInviteRepo extends Context.Service()("Con ) const db = yield* Database.Database - const findActiveById = (id: ConnectInviteId, tx?: TxFn) => - db - .makeQuery((execute, input: ConnectInviteId) => - execute((client) => - client - .select() - .from(schema.connectInvitesTable) - .where( - and( - eq(schema.connectInvitesTable.id, input), - isNull(schema.connectInvitesTable.deletedAt), - ), - ) - .limit(1), - ), - )(id, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findActiveById = (id: ConnectInviteId) => + db.first( + PG.from(schema.connectInvitesTable) + .select() + .where(($) => [$.id.eq(id), $.deletedAt.isNull()]) + .limit(1), + ) - const listIncomingForOrganization = (organizationId: OrganizationId, tx?: TxFn) => - db.makeQuery((execute, input: OrganizationId) => - execute((client) => - client - .select() - .from(schema.connectInvitesTable) - .where( - and( - eq(schema.connectInvitesTable.guestOrganizationId, input), - isNull(schema.connectInvitesTable.deletedAt), - ), - ), - ), - )(organizationId, tx) + const listIncomingForOrganization = (organizationId: OrganizationId) => + db.run( + PG.from(schema.connectInvitesTable) + .select() + .where(($) => [$.guestOrganizationId.eq(organizationId), $.deletedAt.isNull()]), + ) - const listOutgoingForOrganization = (organizationId: OrganizationId, tx?: TxFn) => - db.makeQuery((execute, input: OrganizationId) => - execute((client) => - client - .select() - .from(schema.connectInvitesTable) - .where( - and( - eq(schema.connectInvitesTable.hostOrganizationId, input), - isNull(schema.connectInvitesTable.deletedAt), - ), - ), - ), - )(organizationId, tx) + const listOutgoingForOrganization = (organizationId: OrganizationId) => + db.run( + PG.from(schema.connectInvitesTable) + .select() + .where(($) => [$.hostOrganizationId.eq(organizationId), $.deletedAt.isNull()]), + ) - const findPendingForGuestOrganization = (organizationId: OrganizationId, tx?: TxFn) => - db.makeQuery((execute, input: OrganizationId) => - execute((client) => - client - .select() - .from(schema.connectInvitesTable) - .where( - and( - eq(schema.connectInvitesTable.status, "pending"), - isNull(schema.connectInvitesTable.deletedAt), - or( - eq(schema.connectInvitesTable.guestOrganizationId, input), - eq(schema.connectInvitesTable.targetKind, "email"), - ), - ), - ), - ), - )(organizationId, tx) + const findPendingForGuestOrganization = (organizationId: OrganizationId) => + db.run( + PG.from(schema.connectInvitesTable) + .select() + .where(($) => [ + $.status.eq("pending"), + $.deletedAt.isNull(), + $.guestOrganizationId.eq(organizationId).or($.targetKind.eq("email")), + ]), + ) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/connect-participant-repo.ts b/packages/backend-core/src/repositories/connect-participant-repo.ts index 63ab65dee..157a36ee8 100644 --- a/packages/backend-core/src/repositories/connect-participant-repo.ts +++ b/packages/backend-core/src/repositories/connect-participant-repo.ts @@ -1,4 +1,4 @@ -import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { ChannelId, ConnectConversationId, UserId } from "@hazel/schema" import { ConnectParticipant } from "@hazel/domain/models" import { Context, Effect, Layer, Option, type Schema as EffectSchema } from "effect" @@ -17,86 +17,54 @@ export class ConnectParticipantRepo extends Context.Service - db - .makeQuery((execute, input: { channelId: ChannelId; userId: UserId }) => - execute((client) => - client - .select() - .from(schema.connectParticipantsTable) - .where( - and( - eq(schema.connectParticipantsTable.channelId, input.channelId), - eq(schema.connectParticipantsTable.userId, input.userId), - isNull(schema.connectParticipantsTable.deletedAt), - ), - ) - .limit(1), - ), - )({ channelId, userId }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByChannelAndUser = (channelId: ChannelId, userId: UserId) => + db.first( + PG.from(schema.connectParticipantsTable) + .select() + .where(($) => [$.channelId.eq(channelId), $.userId.eq(userId), $.deletedAt.isNull()]) + .limit(1), + ) - const listByChannel = (channelId: ChannelId, tx?: TxFn) => - db.makeQuery((execute, input: ChannelId) => - execute((client) => - client - .select() - .from(schema.connectParticipantsTable) - .where( - and( - eq(schema.connectParticipantsTable.channelId, input), - isNull(schema.connectParticipantsTable.deletedAt), - ), - ), - ), - )(channelId, tx) + const listByChannel = (channelId: ChannelId) => + db.run( + PG.from(schema.connectParticipantsTable) + .select() + .where(($) => [$.channelId.eq(channelId), $.deletedAt.isNull()]), + ) - const listByConversation = (conversationId: ConnectConversationId, tx?: TxFn) => - db.makeQuery((execute, input: ConnectConversationId) => - execute((client) => - client - .select() - .from(schema.connectParticipantsTable) - .where( - and( - eq(schema.connectParticipantsTable.conversationId, input), - isNull(schema.connectParticipantsTable.deletedAt), - ), - ), - ), - )(conversationId, tx) + const listByConversation = (conversationId: ConnectConversationId) => + db.run( + PG.from(schema.connectParticipantsTable) + .select() + .where(($) => [$.conversationId.eq(conversationId), $.deletedAt.isNull()]), + ) const upsertByChannelAndUser = ( data: EffectSchema.Schema.Type, - tx?: TxFn, ) => - baseRepo.insert(data, tx).pipe( + baseRepo.insert(data).pipe( Effect.map((results) => results[0]!), Effect.catchTag("DatabaseError", (error) => { if ( error.type !== "unique_violation" || - (error.cause as { constraint_name?: string } | undefined)?.constraint_name !== - "connect_participants_channel_user_unique" + error.constraint !== "connect_participants_channel_user_unique" ) { return Effect.fail(error) } - return findByChannelAndUser(data.channelId, data.userId, tx).pipe( + return findByChannelAndUser(data.channelId, data.userId).pipe( Effect.flatMap( Option.match({ onNone: () => Effect.fail(error), onSome: (existing) => - baseRepo.update( - { - id: existing.id, - conversationId: data.conversationId, - homeOrganizationId: data.homeOrganizationId, - isExternal: data.isExternal, - addedBy: data.addedBy, - deletedAt: data.deletedAt, - }, - tx, - ), + baseRepo.update({ + id: existing.id, + conversationId: data.conversationId, + homeOrganizationId: data.homeOrganizationId, + isExternal: data.isExternal, + addedBy: data.addedBy, + deletedAt: data.deletedAt, + }), }), ), ) diff --git a/packages/backend-core/src/repositories/custom-emoji-repo.ts b/packages/backend-core/src/repositories/custom-emoji-repo.ts index eff135526..8a56c7331 100644 --- a/packages/backend-core/src/repositories/custom-emoji-repo.ts +++ b/packages/backend-core/src/repositories/custom-emoji-repo.ts @@ -1,8 +1,8 @@ -import { and, Database, eq, isNotNull, isNull, Repository, schema } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { CustomEmojiId, OrganizationId } from "@hazel/schema" import { CustomEmoji } from "@hazel/domain/models" -import { Context, Effect, Layer, Option } from "effect" +import { Context, Effect, Layer } from "effect" export class CustomEmojiRepo extends Context.Service()("CustomEmojiRepo", { make: Effect.gen(function* () { @@ -17,82 +17,48 @@ export class CustomEmojiRepo extends Context.Service()("CustomE const db = yield* Database.Database const findByOrgAndName = (organizationId: OrganizationId, name: string) => - db - .makeQuery((execute, data: { organizationId: OrganizationId; name: string }) => - execute((client) => - client - .select() - .from(schema.customEmojisTable) - .where( - and( - eq(schema.customEmojisTable.organizationId, data.organizationId), - eq(schema.customEmojisTable.name, data.name), - isNull(schema.customEmojisTable.deletedAt), - ), - ) - .limit(1), - ), - )({ organizationId, name }) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + db.first( + PG.from(schema.customEmojisTable) + .select() + .where(($) => [ + $.organizationId.eq(organizationId), + $.name.eq(name), + $.deletedAt.isNull(), + ]) + .limit(1), + ) const findDeletedByOrgAndName = (organizationId: OrganizationId, name: string) => - db - .makeQuery((execute, data: { organizationId: OrganizationId; name: string }) => - execute((client) => - client - .select() - .from(schema.customEmojisTable) - .where( - and( - eq(schema.customEmojisTable.organizationId, data.organizationId), - eq(schema.customEmojisTable.name, data.name), - isNotNull(schema.customEmojisTable.deletedAt), - ), - ) - .limit(1), - ), - )({ organizationId, name }) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + db.first( + PG.from(schema.customEmojisTable) + .select() + .where(($) => [ + $.organizationId.eq(organizationId), + $.name.eq(name), + $.deletedAt.isNotNull(), + ]) + .limit(1), + ) const restore = (id: CustomEmojiId, imageUrl?: string) => - db - .makeQuery((execute, data: { id: CustomEmojiId; imageUrl?: string }) => - execute((client) => - client - .update(schema.customEmojisTable) - .set({ - deletedAt: null, - updatedAt: new Date(), - ...(data.imageUrl !== undefined ? { imageUrl: data.imageUrl } : {}), - }) - .where( - and( - eq(schema.customEmojisTable.id, data.id), - isNotNull(schema.customEmojisTable.deletedAt), - ), - ) - .returning(), - ), - )({ id, imageUrl }) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + db.first( + PG.update(schema.customEmojisTable) + .set({ + deletedAt: null, + updatedAt: new Date(), + ...(imageUrl !== undefined ? { imageUrl } : {}), + }) + .where(($) => [$.id.eq(id), $.deletedAt.isNotNull()]) + .returning(), + ) const softDelete = (id: CustomEmojiId) => - db - .makeQuery((execute, emojiId: CustomEmojiId) => - execute((client) => - client - .update(schema.customEmojisTable) - .set({ deletedAt: new Date() }) - .where( - and( - eq(schema.customEmojisTable.id, emojiId), - isNull(schema.customEmojisTable.deletedAt), - ), - ) - .returning(), - ), - )(id) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + db.first( + PG.update(schema.customEmojisTable) + .set({ deletedAt: new Date() }) + .where(($) => [$.id.eq(id), $.deletedAt.isNull()]) + .returning(), + ) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/github-subscription-repo.ts b/packages/backend-core/src/repositories/github-subscription-repo.ts index 74580e6fc..95607e050 100644 --- a/packages/backend-core/src/repositories/github-subscription-repo.ts +++ b/packages/backend-core/src/repositories/github-subscription-repo.ts @@ -1,8 +1,8 @@ -import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { ChannelId, GitHubSubscriptionId, OrganizationId } from "@hazel/schema" import { GitHubSubscription } from "@hazel/domain/models" -import { Context, Effect, Layer, Option } from "effect" +import { Context, Effect, Layer } from "effect" export class GitHubSubscriptionRepo extends Context.Service()( "GitHubSubscriptionRepo", @@ -19,56 +19,33 @@ export class GitHubSubscriptionRepo extends Context.Service - db.makeQuery((execute, data: { channelId: ChannelId }) => - execute((client) => - client - .select() - .from(schema.githubSubscriptionsTable) - .where( - and( - eq(schema.githubSubscriptionsTable.channelId, data.channelId), - isNull(schema.githubSubscriptionsTable.deletedAt), - ), - ), - ), - )({ channelId }, tx) + const findByChannel = (channelId: ChannelId) => + db.run( + PG.from(schema.githubSubscriptionsTable) + .select() + .where(($) => [$.channelId.eq(channelId), $.deletedAt.isNull()]), + ) // Find all subscriptions for an organization - const findByOrganization = (organizationId: OrganizationId, tx?: TxFn) => - db.makeQuery((execute, data: { organizationId: OrganizationId }) => - execute((client) => - client - .select() - .from(schema.githubSubscriptionsTable) - .where( - and( - eq(schema.githubSubscriptionsTable.organizationId, data.organizationId), - isNull(schema.githubSubscriptionsTable.deletedAt), - ), - ), - ), - )({ organizationId }, tx) + const findByOrganization = (organizationId: OrganizationId) => + db.run( + PG.from(schema.githubSubscriptionsTable) + .select() + .where(($) => [$.organizationId.eq(organizationId), $.deletedAt.isNull()]), + ) // Find subscription by channel and repository (for uniqueness check) - const findByChannelAndRepo = (channelId: ChannelId, repositoryId: number, tx?: TxFn) => - db - .makeQuery((execute, data: { channelId: ChannelId; repositoryId: number }) => - execute((client) => - client - .select() - .from(schema.githubSubscriptionsTable) - .where( - and( - eq(schema.githubSubscriptionsTable.channelId, data.channelId), - eq(schema.githubSubscriptionsTable.repositoryId, data.repositoryId), - isNull(schema.githubSubscriptionsTable.deletedAt), - ), - ) - .limit(1), - ), - )({ channelId, repositoryId }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByChannelAndRepo = (channelId: ChannelId, repositoryId: number) => + db.first( + PG.from(schema.githubSubscriptionsTable) + .select() + .where(($) => [ + $.channelId.eq(channelId), + $.repositoryId.eq(repositoryId), + $.deletedAt.isNull(), + ]) + .limit(1), + ) // Update subscription settings const updateSettings = ( @@ -78,50 +55,34 @@ export class GitHubSubscriptionRepo extends Context.Service - db.makeQuery( - ( - execute, - data: { - id: GitHubSubscriptionId - enabledEvents?: GitHubSubscription.GitHubEventType[] - branchFilter?: string | null - isEnabled?: boolean - }, - ) => - execute((client) => - client - .update(schema.githubSubscriptionsTable) - .set({ - ...(data.enabledEvents !== undefined && { - enabledEvents: data.enabledEvents, - }), - ...(data.branchFilter !== undefined && { - branchFilter: data.branchFilter, - }), - ...(data.isEnabled !== undefined && { isEnabled: data.isEnabled }), - updatedAt: new Date(), - }) - .where(eq(schema.githubSubscriptionsTable.id, data.id)) - .returning(), - ), - )({ id, ...settings }, tx) + db.run( + PG.update(schema.githubSubscriptionsTable) + .set({ + ...(settings.enabledEvents !== undefined && { + enabledEvents: settings.enabledEvents, + }), + ...(settings.branchFilter !== undefined && { + branchFilter: settings.branchFilter, + }), + ...(settings.isEnabled !== undefined && { isEnabled: settings.isEnabled }), + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) // Soft delete subscription - const softDelete = (id: GitHubSubscriptionId, tx?: TxFn) => - db.makeQuery((execute, data: { id: GitHubSubscriptionId }) => - execute((client) => - client - .update(schema.githubSubscriptionsTable) - .set({ - deletedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(schema.githubSubscriptionsTable.id, data.id)) - .returning(), - ), - )({ id }, tx) + const softDelete = (id: GitHubSubscriptionId) => + db.run( + PG.update(schema.githubSubscriptionsTable) + .set({ + deletedAt: new Date(), + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/integration-connection-repo.ts b/packages/backend-core/src/repositories/integration-connection-repo.ts index 19aa20d9e..e073b4be4 100644 --- a/packages/backend-core/src/repositories/integration-connection-repo.ts +++ b/packages/backend-core/src/repositories/integration-connection-repo.ts @@ -1,8 +1,12 @@ -import { and, Database, eq, isNull, Repository, schema, sql, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { IntegrationConnectionId, OrganizationId, UserId } from "@hazel/schema" import { IntegrationConnection } from "@hazel/domain/models" -import { Context, Effect, Layer, Option } from "effect" +import { Context, DateTime, Effect, Layer, Option } from "effect" + +/** Domain timestamps may be a `DateTime.Utc`; the timestamp columns take a `Date`. */ +const toDate = (value: A) => + (DateTime.isDateTime(value) ? DateTime.toDate(value) : value) as Exclude | Date export class IntegrationConnectionRepo extends Context.Service()( "IntegrationConnectionRepo", @@ -22,75 +26,38 @@ export class IntegrationConnectionRepo extends Context.Service - db - .makeQuery( - ( - execute, - data: { - organizationId: OrganizationId - provider: IntegrationConnection.IntegrationProvider - }, - ) => - execute((client) => - client - .select() - .from(schema.integrationConnectionsTable) - .where( - and( - eq( - schema.integrationConnectionsTable.organizationId, - data.organizationId, - ), - eq(schema.integrationConnectionsTable.provider, data.provider), - isNull(schema.integrationConnectionsTable.userId), - eq(schema.integrationConnectionsTable.level, "organization"), - isNull(schema.integrationConnectionsTable.deletedAt), - ), - ) - .limit(1), - ), - )({ organizationId, provider }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + db.first( + PG.from(schema.integrationConnectionsTable) + .select() + .where(($) => [ + $.organizationId.eq(organizationId), + $.provider.eq(provider), + $.userId.isNull(), + $.level.eq("organization"), + $.deletedAt.isNull(), + ]) + .limit(1), + ) // Find user-level connection for a specific provider const findUserConnection = ( organizationId: OrganizationId, userId: UserId, provider: IntegrationConnection.IntegrationProvider, - tx?: TxFn, ) => - db - .makeQuery( - ( - execute, - data: { - organizationId: OrganizationId - userId: UserId - provider: IntegrationConnection.IntegrationProvider - }, - ) => - execute((client) => - client - .select() - .from(schema.integrationConnectionsTable) - .where( - and( - eq( - schema.integrationConnectionsTable.organizationId, - data.organizationId, - ), - eq(schema.integrationConnectionsTable.userId, data.userId), - eq(schema.integrationConnectionsTable.provider, data.provider), - eq(schema.integrationConnectionsTable.level, "user"), - isNull(schema.integrationConnectionsTable.deletedAt), - ), - ) - .limit(1), - ), - )({ organizationId, userId, provider }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + db.first( + PG.from(schema.integrationConnectionsTable) + .select() + .where(($) => [ + $.organizationId.eq(organizationId), + $.userId.eq(userId), + $.provider.eq(provider), + $.level.eq("user"), + $.deletedAt.isNull(), + ]) + .limit(1), + ) // Find user-level connection for a specific provider, including soft-deleted rows. // Used by upsert to reactivate previously disconnected links. @@ -98,249 +65,148 @@ export class IntegrationConnectionRepo extends Context.Service - db - .makeQuery( - ( - execute, - data: { - organizationId: OrganizationId - userId: UserId - provider: IntegrationConnection.IntegrationProvider - }, - ) => - execute((client) => - client - .select() - .from(schema.integrationConnectionsTable) - .where( - and( - eq( - schema.integrationConnectionsTable.organizationId, - data.organizationId, - ), - eq(schema.integrationConnectionsTable.userId, data.userId), - eq(schema.integrationConnectionsTable.provider, data.provider), - eq(schema.integrationConnectionsTable.level, "user"), - ), - ) - .limit(1), - ), - )({ organizationId, userId, provider }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + db.first( + PG.from(schema.integrationConnectionsTable) + .select() + .where(($) => [ + $.organizationId.eq(organizationId), + $.userId.eq(userId), + $.provider.eq(provider), + $.level.eq("user"), + ]) + .limit(1), + ) // Find active user-level connection by external account ID. const findActiveUserByExternalAccountId = ( organizationId: OrganizationId, provider: IntegrationConnection.IntegrationProvider, externalAccountId: string, - tx?: TxFn, ) => - db - .makeQuery( - ( - execute, - data: { - organizationId: OrganizationId - provider: IntegrationConnection.IntegrationProvider - externalAccountId: string - }, - ) => - execute((client) => - client - .select() - .from(schema.integrationConnectionsTable) - .where( - and( - eq( - schema.integrationConnectionsTable.organizationId, - data.organizationId, - ), - eq(schema.integrationConnectionsTable.provider, data.provider), - eq( - schema.integrationConnectionsTable.externalAccountId, - data.externalAccountId, - ), - eq(schema.integrationConnectionsTable.level, "user"), - eq(schema.integrationConnectionsTable.status, "active"), - isNull(schema.integrationConnectionsTable.deletedAt), - ), - ) - .limit(1), - ), - )({ organizationId, provider, externalAccountId }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + db.first( + PG.from(schema.integrationConnectionsTable) + .select() + .where(($) => [ + $.organizationId.eq(organizationId), + $.provider.eq(provider), + $.externalAccountId.eq(externalAccountId), + $.level.eq("user"), + $.status.eq("active"), + $.deletedAt.isNull(), + ]) + .limit(1), + ) // Get all connections for an organization (both org-level and user-level) - const findAllForOrg = (organizationId: OrganizationId, tx?: TxFn) => - db.makeQuery((execute, data: { organizationId: OrganizationId }) => - execute((client) => - client - .select() - .from(schema.integrationConnectionsTable) - .where( - and( - eq( - schema.integrationConnectionsTable.organizationId, - data.organizationId, - ), - isNull(schema.integrationConnectionsTable.deletedAt), - ), - ), - ), - )({ organizationId }, tx) + const findAllForOrg = (organizationId: OrganizationId) => + db.run( + PG.from(schema.integrationConnectionsTable) + .select() + .where(($) => [$.organizationId.eq(organizationId), $.deletedAt.isNull()]), + ) // Get all active org-level connections for an organization - const findActiveOrgConnections = (organizationId: OrganizationId, tx?: TxFn) => - db.makeQuery((execute, data: { organizationId: OrganizationId }) => - execute((client) => - client - .select() - .from(schema.integrationConnectionsTable) - .where( - and( - eq( - schema.integrationConnectionsTable.organizationId, - data.organizationId, - ), - eq(schema.integrationConnectionsTable.level, "organization"), - eq(schema.integrationConnectionsTable.status, "active"), - isNull(schema.integrationConnectionsTable.userId), - isNull(schema.integrationConnectionsTable.deletedAt), - ), - ), - ), - )({ organizationId }, tx) + const findActiveOrgConnections = (organizationId: OrganizationId) => + db.run( + PG.from(schema.integrationConnectionsTable) + .select() + .where(($) => [ + $.organizationId.eq(organizationId), + $.level.eq("organization"), + $.status.eq("active"), + $.userId.isNull(), + $.deletedAt.isNull(), + ]), + ) // Update connection status const updateStatus = ( connectionId: IntegrationConnectionId, status: IntegrationConnection.ConnectionStatus, errorMessage?: string, - tx?: TxFn, ) => - db.makeQuery( - ( - execute, - data: { - connectionId: IntegrationConnectionId - status: IntegrationConnection.ConnectionStatus - errorMessage?: string - }, - ) => - execute((client) => - client - .update(schema.integrationConnectionsTable) - .set({ - status: data.status, - errorMessage: data.errorMessage ?? null, - updatedAt: new Date(), - }) - .where(eq(schema.integrationConnectionsTable.id, data.connectionId)) - .returning(), - ), - )({ connectionId, status, errorMessage }, tx) + db.run( + PG.update(schema.integrationConnectionsTable) + .set({ + status, + errorMessage: errorMessage ?? null, + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(connectionId)]) + .returning(), + ) // Soft delete connection - const softDelete = (connectionId: IntegrationConnectionId, tx?: TxFn) => - db.makeQuery((execute, data: { connectionId: IntegrationConnectionId }) => - execute((client) => - client - .update(schema.integrationConnectionsTable) - .set({ - deletedAt: new Date(), - status: "revoked", - updatedAt: new Date(), - }) - .where(eq(schema.integrationConnectionsTable.id, data.connectionId)) - .returning(), - ), - )({ connectionId }, tx) + const softDelete = (connectionId: IntegrationConnectionId) => + db.run( + PG.update(schema.integrationConnectionsTable) + .set({ + deletedAt: new Date(), + status: "revoked", + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(connectionId)]) + .returning(), + ) // Find connection by GitHub installation ID (stored in metadata JSONB) - const findByGitHubInstallationId = (installationId: string, tx?: TxFn) => - db - .makeQuery((execute, data: { installationId: string }) => - execute((client) => - client - .select() - .from(schema.integrationConnectionsTable) - .where( - and( - eq(schema.integrationConnectionsTable.provider, "github"), - sql`${schema.integrationConnectionsTable.metadata}->>'installationId' = ${data.installationId}`, - isNull(schema.integrationConnectionsTable.deletedAt), - ), - ) - .limit(1), - ), - )({ installationId }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByGitHubInstallationId = (installationId: string) => + db.first( + PG.from(schema.integrationConnectionsTable) + .select() + .where(($) => [ + $.provider.eq("github"), + PG.jsonText($.metadata, "installationId").eq(installationId), + $.deletedAt.isNull(), + ]) + .limit(1), + ) // Find all connections for a GitHub installation ID (stored in metadata JSONB) - const findAllByGitHubInstallationId = (installationId: string, tx?: TxFn) => - db.makeQuery((execute, data: { installationId: string }) => - execute((client) => - client - .select() - .from(schema.integrationConnectionsTable) - .where( - and( - eq(schema.integrationConnectionsTable.provider, "github"), - sql`${schema.integrationConnectionsTable.metadata}->>'installationId' = ${data.installationId}`, - isNull(schema.integrationConnectionsTable.deletedAt), - ), - ), - ), - )({ installationId }, tx) + const findAllByGitHubInstallationId = (installationId: string) => + db.run( + PG.from(schema.integrationConnectionsTable) + .select() + .where(($) => [ + $.provider.eq("github"), + PG.jsonText($.metadata, "installationId").eq(installationId), + $.deletedAt.isNull(), + ]), + ) + + // Overwrite an existing connection with the given insert data + const overwrite = (id: IntegrationConnectionId, data: typeof IntegrationConnection.Insert.Type) => + db.run( + PG.update(schema.integrationConnectionsTable) + .set({ + ...data, + lastUsedAt: toDate(data.lastUsedAt), + deletedAt: toDate(data.deletedAt), + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) // Upsert org-level connection for a provider - const upsertByOrgAndProvider = ( - insertData: typeof IntegrationConnection.Insert.Type, - tx?: TxFn, - ) => + const upsertByOrgAndProvider = (insertData: typeof IntegrationConnection.Insert.Type) => Effect.gen(function* () { - const existing = yield* findOrgConnection( - insertData.organizationId, - insertData.provider, - tx, - ) + const existing = yield* findOrgConnection(insertData.organizationId, insertData.provider) if (Option.isSome(existing)) { // Update existing connection - const results = yield* db.makeQuery( - ( - execute, - updateParams: { - id: IntegrationConnectionId - data: Partial - }, - ) => - execute((client) => - client - .update(schema.integrationConnectionsTable) - .set({ - ...updateParams.data, - updatedAt: new Date(), - } as any) - .where(eq(schema.integrationConnectionsTable.id, updateParams.id)) - .returning(), - ), - )({ id: existing.value.id, data: insertData }, tx) + const results = yield* overwrite(existing.value.id, insertData) return results[0]! } // Insert new connection - returns array, take first element - const inserted = yield* baseRepo.insert(insertData, tx) + const inserted = yield* baseRepo.insert(insertData) return inserted[0]! }) // Upsert user-level connection for a provider. - const upsertByUserAndProvider = ( - insertData: typeof IntegrationConnection.Insert.Type, - tx?: TxFn, - ) => + const upsertByUserAndProvider = (insertData: typeof IntegrationConnection.Insert.Type) => Effect.gen(function* () { if (!insertData.userId) { return yield* Effect.die( @@ -352,33 +218,14 @@ export class IntegrationConnectionRepo extends Context.Service - }, - ) => - execute((client) => - client - .update(schema.integrationConnectionsTable) - .set({ - ...updateParams.data, - updatedAt: new Date(), - } as any) - .where(eq(schema.integrationConnectionsTable.id, updateParams.id)) - .returning(), - ), - )({ id: existing.value.id, data: insertData }, tx) + const results = yield* overwrite(existing.value.id, insertData) return results[0]! } - const inserted = yield* baseRepo.insert(insertData, tx) + const inserted = yield* baseRepo.insert(insertData) return inserted[0]! }) diff --git a/packages/backend-core/src/repositories/integration-token-repo.ts b/packages/backend-core/src/repositories/integration-token-repo.ts index e6181d923..50b675625 100644 --- a/packages/backend-core/src/repositories/integration-token-repo.ts +++ b/packages/backend-core/src/repositories/integration-token-repo.ts @@ -1,8 +1,8 @@ -import { Database, eq, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { IntegrationConnectionId, IntegrationTokenId } from "@hazel/schema" import { IntegrationToken } from "@hazel/domain/models" -import { Context, Effect, Layer, Option } from "effect" +import { Context, Effect, Layer } from "effect" export class IntegrationTokenRepo extends Context.Service()("IntegrationTokenRepo", { make: Effect.gen(function* () { @@ -17,18 +17,13 @@ export class IntegrationTokenRepo extends Context.Service( const db = yield* Database.Database // Find token by connection ID - const findByConnectionId = (connectionId: IntegrationConnectionId, tx?: TxFn) => - db - .makeQuery((execute, data: { connectionId: IntegrationConnectionId }) => - execute((client) => - client - .select() - .from(schema.integrationTokensTable) - .where(eq(schema.integrationTokensTable.connectionId, data.connectionId)) - .limit(1), - ), - )({ connectionId }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByConnectionId = (connectionId: IntegrationConnectionId) => + db.first( + PG.from(schema.integrationTokensTable) + .select() + .where(($) => [$.connectionId.eq(connectionId)]) + .limit(1), + ) // Update token (for refresh) const updateToken = ( @@ -42,50 +37,29 @@ export class IntegrationTokenRepo extends Context.Service( expiresAt?: Date | null scope?: string | null }, - tx?: TxFn, ) => - db.makeQuery( - ( - execute, - params: { - tokenId: IntegrationTokenId - encryptedAccessToken: string - encryptedRefreshToken?: string | null - iv: string - refreshTokenIv?: string | null - encryptionKeyVersion: number - expiresAt?: Date | null - scope?: string | null - }, - ) => - execute((client) => - client - .update(schema.integrationTokensTable) - .set({ - encryptedAccessToken: params.encryptedAccessToken, - encryptedRefreshToken: params.encryptedRefreshToken, - iv: params.iv, - refreshTokenIv: params.refreshTokenIv, - encryptionKeyVersion: params.encryptionKeyVersion, - expiresAt: params.expiresAt, - scope: params.scope, - lastRefreshedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(schema.integrationTokensTable.id, params.tokenId)) - .returning(), - ), - )({ tokenId, ...data }, tx) + db.run( + PG.update(schema.integrationTokensTable) + .set({ + encryptedAccessToken: data.encryptedAccessToken, + encryptedRefreshToken: data.encryptedRefreshToken, + iv: data.iv, + refreshTokenIv: data.refreshTokenIv, + encryptionKeyVersion: data.encryptionKeyVersion, + expiresAt: data.expiresAt, + scope: data.scope, + lastRefreshedAt: new Date(), + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(tokenId)]) + .returning(), + ) // Delete token (hard delete for security) - const deleteByConnectionId = (connectionId: IntegrationConnectionId, tx?: TxFn) => - db.makeQuery((execute, data: { connectionId: IntegrationConnectionId }) => - execute((client) => - client - .delete(schema.integrationTokensTable) - .where(eq(schema.integrationTokensTable.connectionId, data.connectionId)), - ), - )({ connectionId }, tx) + const deleteByConnectionId = (connectionId: IntegrationConnectionId) => + db.run( + PG.deleteFrom(schema.integrationTokensTable).where(($) => [$.connectionId.eq(connectionId)]), + ) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/message-outbox-repo.ts b/packages/backend-core/src/repositories/message-outbox-repo.ts index bb90f71e4..6fd016e35 100644 --- a/packages/backend-core/src/repositories/message-outbox-repo.ts +++ b/packages/backend-core/src/repositories/message-outbox-repo.ts @@ -1,7 +1,6 @@ -import { Database, and, asc, eq, inArray, or, schema, sql } from "@hazel/db" -import type { DatabaseError, TxFn } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { ChannelId, MessageId, MessageOutboxEventId, MessageReactionId, UserId } from "@hazel/schema" -import { Context, Effect, Layer, Option, Schema } from "effect" +import { Context, Effect, Layer, Schema } from "effect" export const MessageCreatedPayloadSchema = Schema.Struct({ messageId: MessageId, @@ -80,7 +79,7 @@ export interface FailMessageOutboxEventParams { readonly lastError: string } -export type MessageOutboxEventRecord = typeof schema.messageOutboxEventsTable.$inferSelect +export type MessageOutboxEventRecord = schema.MessageOutboxEvent const InsertMessageOutboxEventSchema = Schema.Struct({ eventType: MessageOutboxEventType, @@ -109,171 +108,115 @@ export class MessageOutboxRepo extends Context.Service()("Mes make: Effect.gen(function* () { const db = yield* Database.Database - const insert = (data: InsertMessageOutboxEvent, tx?: TxFn) => - db - .makeQueryWithSchema(InsertMessageOutboxEventSchema, (execute, input) => - execute((client) => - client - .insert(schema.messageOutboxEventsTable) - .values({ - eventType: input.eventType, - aggregateId: input.aggregateId, - channelId: input.channelId, - payload: input.payload as Record, - }) - .returning(), - ), - )(data, tx) - .pipe(Effect.tap(() => markOutboxWrite)) - - const insertMany = (data: ReadonlyArray, tx?: TxFn) => - db - .makeQueryWithSchema(InsertMessageOutboxEventArraySchema, (execute, input) => - execute((client) => - client - .insert(schema.messageOutboxEventsTable) - .values( - input.map((event) => ({ - eventType: event.eventType, - aggregateId: event.aggregateId, - channelId: event.channelId, - payload: event.payload as Record, - })), - ) - .returning(), - ), - )(data, tx) - .pipe(Effect.tap(() => markOutboxWrite)) - - const claimNextBatch = (params: ClaimNextBatchParams, tx?: TxFn) => - db.makeQuery((execute, data: ClaimNextBatchParams) => { - const staleBefore = new Date(Date.now() - data.lockTimeoutMs).toISOString() - const lockedAt = new Date().toISOString() - return execute( - (client) => - ( - client as typeof client & { - $client: { - ( - strings: TemplateStringsArray, - ...values: ReadonlyArray - ): Promise - } - } - ).$client>` - WITH candidates AS ( - SELECT id - FROM "message_outbox_events" - WHERE ( - "status" = 'pending' - AND "availableAt" <= now() - ) OR ( - "status" = 'processing' - AND "lockedAt" < ${staleBefore} - ) - ORDER BY "sequence" ASC - LIMIT ${data.limit} - FOR UPDATE SKIP LOCKED + const insert = (data: InsertMessageOutboxEvent) => + Effect.gen(function* () { + const input = yield* Schema.decodeUnknownEffect(InsertMessageOutboxEventSchema)(data) + return yield* db.run( + PG.insertInto(schema.messageOutboxEventsTable) + .values({ + eventType: input.eventType, + aggregateId: input.aggregateId, + channelId: input.channelId, + payload: input.payload as Record, + }) + .returning(), + ) + }).pipe(Effect.tap(() => markOutboxWrite)) + + const insertMany = (data: ReadonlyArray) => + Effect.gen(function* () { + const input = yield* Schema.decodeUnknownEffect(InsertMessageOutboxEventArraySchema)(data) + return yield* db.run( + PG.insertInto(schema.messageOutboxEventsTable) + .values( + input.map((event) => ({ + eventType: event.eventType, + aggregateId: event.aggregateId, + channelId: event.channelId, + payload: event.payload as Record, + })), ) - UPDATE "message_outbox_events" AS events - SET - "status" = 'processing', - "lockedAt" = ${lockedAt}, - "lockedBy" = ${data.workerId} - FROM candidates - WHERE events.id = candidates.id - RETURNING events.*; - `, - ).pipe( - Effect.map( - (rows) => - rows - .slice() - .sort( - (left, right) => left.sequence - right.sequence, - ) as Array, - ), + .returning(), ) - })(params, tx) - - const markProcessed = (id: MessageOutboxEventId, tx?: TxFn) => - db.makeQuery((execute, eventId: MessageOutboxEventId) => - execute((client) => - client - .update(schema.messageOutboxEventsTable) + }).pipe(Effect.tap(() => markOutboxWrite)) + + /** + * Claims up to `limit` events: pending events that are due, plus processing events whose lock + * went stale. Candidates are locked with FOR UPDATE SKIP LOCKED so concurrent workers never + * claim the same event. + */ + const claimNextBatch = (params: ClaimNextBatchParams) => + Effect.gen(function* () { + const staleBefore = new Date(Date.now() - params.lockTimeoutMs) + const lockedAt = new Date() + + const candidates = PG.from(schema.messageOutboxEventsTable) + .select("id") + .where(($) => [ + PG.or( + $.status.eq("pending").and($.availableAt.lte(schema.now())), + $.status.eq("processing").and($.lockedAt.lt(staleBefore)), + ), + ]) + .orderBy(($) => [[$.sequence, "asc"]]) + .limit(params.limit) + .forUpdate({ skipLocked: true }) + + const rows = yield* db.run( + PG.update(schema.messageOutboxEventsTable) .set({ - status: "processed", - processedAt: new Date(), - lockedAt: null, - lockedBy: null, - lastError: null, + status: "processing", + lockedAt, + lockedBy: params.workerId, }) - .where(eq(schema.messageOutboxEventsTable.id, eventId)) + .where(($) => [PG.inSubquery($.id, candidates)]) .returning(), - ).pipe(Effect.map((rows) => Option.fromNullishOr(rows[0]))), - )(id, tx) + ) + + return rows.slice().sort((left, right) => left.sequence - right.sequence) + }) + + const markProcessed = (id: MessageOutboxEventId) => + db.first( + PG.update(schema.messageOutboxEventsTable) + .set({ + status: "processed", + processedAt: new Date(), + lockedAt: null, + lockedBy: null, + lastError: null, + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) - const markRetry = (id: MessageOutboxEventId, params: RetryMessageOutboxEventParams, tx?: TxFn) => - db.makeQuery( - ( - execute, - data: { - id: MessageOutboxEventId - availableAt: Date - lastError: string - }, - ) => - execute((client) => - client - .update(schema.messageOutboxEventsTable) - .set({ - status: "pending", - availableAt: data.availableAt, - lastError: data.lastError, - lockedAt: null, - lockedBy: null, - attemptCount: sql`${schema.messageOutboxEventsTable.attemptCount} + 1`, - }) - .where(eq(schema.messageOutboxEventsTable.id, data.id)) - .returning(), - ).pipe(Effect.map((rows) => Option.fromNullishOr(rows[0]))), - )( - { - id, - availableAt: params.availableAt, - lastError: params.lastError, - }, - tx, + const markRetry = (id: MessageOutboxEventId, params: RetryMessageOutboxEventParams) => + db.first( + PG.update(schema.messageOutboxEventsTable) + .set(($) => ({ + status: "pending" as const, + availableAt: params.availableAt, + lastError: params.lastError, + lockedAt: null, + lockedBy: null, + attemptCount: $.attemptCount.add(1), + })) + .where(($) => [$.id.eq(id)]) + .returning(), ) - const markFailed = (id: MessageOutboxEventId, params: FailMessageOutboxEventParams, tx?: TxFn) => - db.makeQuery( - ( - execute, - data: { - id: MessageOutboxEventId - lastError: string - }, - ) => - execute((client) => - client - .update(schema.messageOutboxEventsTable) - .set({ - status: "failed", - lastError: data.lastError, - lockedAt: null, - lockedBy: null, - attemptCount: sql`${schema.messageOutboxEventsTable.attemptCount} + 1`, - }) - .where(eq(schema.messageOutboxEventsTable.id, data.id)) - .returning(), - ).pipe(Effect.map((rows) => Option.fromNullishOr(rows[0]))), - )( - { - id, - lastError: params.lastError, - }, - tx, + const markFailed = (id: MessageOutboxEventId, params: FailMessageOutboxEventParams) => + db.first( + PG.update(schema.messageOutboxEventsTable) + .set(($) => ({ + status: "failed" as const, + lastError: params.lastError, + lockedAt: null, + lockedBy: null, + attemptCount: $.attemptCount.add(1), + })) + .where(($) => [$.id.eq(id)]) + .returning(), ) return { diff --git a/packages/backend-core/src/repositories/message-reaction-repo.ts b/packages/backend-core/src/repositories/message-reaction-repo.ts index 8da2ef153..d138a4094 100644 --- a/packages/backend-core/src/repositories/message-reaction-repo.ts +++ b/packages/backend-core/src/repositories/message-reaction-repo.ts @@ -1,8 +1,8 @@ -import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { ChannelId, ConnectConversationId, MessageId, UserId } from "@hazel/schema" import { MessageReaction } from "@hazel/domain/models" -import { Context, Effect, Layer, Option } from "effect" +import { Context, Effect, Layer } from "effect" export class MessageReactionRepo extends Context.Service()("MessageReactionRepo", { make: Effect.gen(function* () { @@ -18,42 +18,22 @@ export class MessageReactionRepo extends Context.Service()( const db = yield* Database.Database const findByMessageUserEmoji = (messageId: MessageId, userId: UserId, emoji: string) => - db - .makeQuery((execute, data: { messageId: MessageId; userId: UserId; emoji: string }) => - execute((client) => - client - .select() - .from(schema.messageReactionsTable) - .where( - and( - eq(schema.messageReactionsTable.messageId, data.messageId), - eq(schema.messageReactionsTable.userId, data.userId), - eq(schema.messageReactionsTable.emoji, data.emoji), - ), - ) - .limit(1), - ), - )({ messageId, userId, emoji }) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + db.first( + PG.from(schema.messageReactionsTable) + .select() + .where(($) => [$.messageId.eq(messageId), $.userId.eq(userId), $.emoji.eq(emoji)]) + .limit(1), + ) const backfillConversationIdForChannel = ( channelId: ChannelId, conversationId: ConnectConversationId, - tx?: TxFn, ) => - db.makeQuery((execute, input: { channelId: ChannelId; conversationId: ConnectConversationId }) => - execute((client) => - client - .update(schema.messageReactionsTable) - .set({ conversationId: input.conversationId }) - .where( - and( - eq(schema.messageReactionsTable.channelId, input.channelId), - isNull(schema.messageReactionsTable.conversationId), - ), - ), - ), - )({ channelId, conversationId }, tx) + db.run( + PG.update(schema.messageReactionsTable) + .set({ conversationId }) + .where(($) => [$.channelId.eq(channelId), $.conversationId.isNull()]), + ) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/message-repo.ts b/packages/backend-core/src/repositories/message-repo.ts index a12c37e40..028f6c4c0 100644 --- a/packages/backend-core/src/repositories/message-repo.ts +++ b/packages/backend-core/src/repositories/message-repo.ts @@ -1,22 +1,8 @@ -import { - and, - or, - Database, - desc, - eq, - gt, - inArray, - isNull, - lt, - Repository, - schema, - sql, - type TxFn, -} from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { ChannelId, ConnectConversationId, MessageId, OrganizationId, UserId } from "@hazel/schema" import { Message } from "@hazel/domain/models" -import { Context, Effect, Layer, Option } from "effect" +import { Context, Effect, Layer } from "effect" export interface ListByChannelParams { channelId: ChannelId @@ -50,198 +36,101 @@ export class MessageRepo extends Context.Service()("MessageRepo", { /** * List messages in a channel with cursor-based pagination (Stripe-style). */ - const listByChannel = (params: ListByChannelParams, tx?: TxFn) => - db.makeQuery( - ( - execute, - data: { - channelId: ChannelId - conversationId?: ConnectConversationId - limit: number - cursorBefore?: { - id: MessageId - createdAt: Date - } - cursorAfter?: { - id: MessageId - createdAt: Date - } - }, - ) => - execute((client) => { - // Build the WHERE conditions - const conditions = [isNull(schema.messagesTable.deletedAt)] - conditions.push( - data.conversationId - ? eq(schema.messagesTable.conversationId, data.conversationId) - : eq(schema.messagesTable.channelId, data.channelId), - ) - if (data.cursorBefore) { - conditions.push( - or( - lt(schema.messagesTable.createdAt, data.cursorBefore.createdAt), - and( - eq(schema.messagesTable.createdAt, data.cursorBefore.createdAt), - lt(schema.messagesTable.id, data.cursorBefore.id), - ), - )!, - ) - } - if (data.cursorAfter) { - conditions.push( - or( - gt(schema.messagesTable.createdAt, data.cursorAfter.createdAt), - and( - eq(schema.messagesTable.createdAt, data.cursorAfter.createdAt), - gt(schema.messagesTable.id, data.cursorAfter.id), - ), - )!, - ) - } - - return client - .select() - .from(schema.messagesTable) - .where(and(...conditions)) - .orderBy(desc(schema.messagesTable.createdAt), desc(schema.messagesTable.id)) - .limit(data.limit + 1) - }), - )(params, tx) + const listByChannel = (params: ListByChannelParams) => + db.run( + PG.from(schema.messagesTable) + .select() + .where(($) => [ + $.deletedAt.isNull(), + params.conversationId + ? $.conversationId.eq(params.conversationId) + : $.channelId.eq(params.channelId), + PG.when(params.cursorBefore, (cursor) => + $.createdAt.lt(cursor.createdAt).or($.createdAt.eq(cursor.createdAt).and($.id.lt(cursor.id))), + ), + PG.when(params.cursorAfter, (cursor) => + $.createdAt.gt(cursor.createdAt).or($.createdAt.eq(cursor.createdAt).and($.id.gt(cursor.id))), + ), + ]) + .orderBy(["createdAt", "desc"], ["id", "desc"]) + .limit(params.limit + 1), + ) /** * Find a message by ID scoped to a channel for cursor resolution. */ - const findByIdForCursor = ( - params: { id: MessageId; channelId: ChannelId; conversationId?: ConnectConversationId }, - tx?: TxFn, - ) => - db - .makeQuery( - ( - execute, - data: { id: MessageId; channelId: ChannelId; conversationId?: ConnectConversationId }, - ) => - execute((client) => - client - .select() - .from(schema.messagesTable) - .where( - and( - eq(schema.messagesTable.id, data.id), - data.conversationId - ? eq(schema.messagesTable.conversationId, data.conversationId) - : eq(schema.messagesTable.channelId, data.channelId), - isNull(schema.messagesTable.deletedAt), - ), - ) - .limit(1), - ), - )(params, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByIdForCursor = (params: { + id: MessageId + channelId: ChannelId + conversationId?: ConnectConversationId + }) => + db.first( + PG.from(schema.messagesTable) + .select() + .where(($) => [ + $.id.eq(params.id), + params.conversationId + ? $.conversationId.eq(params.conversationId) + : $.channelId.eq(params.channelId), + $.deletedAt.isNull(), + ]) + .limit(1), + ) - const backfillConversationIdForChannel = ( - channelId: ChannelId, - conversationId: ConnectConversationId, - tx?: TxFn, - ) => - db.makeQuery((execute, input: { channelId: ChannelId; conversationId: ConnectConversationId }) => - execute((client) => - client - .update(schema.messagesTable) - .set({ - conversationId: input.conversationId, - updatedAt: sql`COALESCE(${schema.messagesTable.updatedAt}, NOW())`, - }) - .where( - and( - eq(schema.messagesTable.channelId, input.channelId), - isNull(schema.messagesTable.deletedAt), - isNull(schema.messagesTable.conversationId), - ), - ), - ), - )({ channelId, conversationId }, tx) + const backfillConversationIdForChannel = (channelId: ChannelId, conversationId: ConnectConversationId) => + db.run( + PG.update(schema.messagesTable) + .set(($) => ({ + conversationId, + updatedAt: PG.coalesce($.updatedAt, schema.now()), + })) + .where(($) => [$.channelId.eq(channelId), $.deletedAt.isNull(), $.conversationId.isNull()]), + ) /** * Reassign message authors for external chat-synced messages scoped to a provider + org. * Used when a user links/unlinks their external account and historical messages need re-attribution. */ - const reassignExternalSyncedAuthors = ( - params: { - organizationId: OrganizationId - provider: string - fromAuthorId: UserId - toAuthorId: UserId - }, - tx?: TxFn, - ) => - db.makeQuery( - ( - execute, - data: { - organizationId: OrganizationId - provider: string - fromAuthorId: UserId - toAuthorId: UserId - }, - ) => - execute(async (client) => { - if (data.fromAuthorId === data.toAuthorId) { - return 0 - } + const reassignExternalSyncedAuthors = (params: { + organizationId: OrganizationId + provider: string + fromAuthorId: UserId + toAuthorId: UserId + }) => + Effect.gen(function* () { + if (params.fromAuthorId === params.toAuthorId) { + return 0 + } - const externalLinkedMessageIds = client - .select({ - hazelMessageId: schema.chatSyncMessageLinksTable.hazelMessageId, - }) - .from(schema.chatSyncMessageLinksTable) - .innerJoin( - schema.chatSyncChannelLinksTable, - and( - eq( - schema.chatSyncChannelLinksTable.id, - schema.chatSyncMessageLinksTable.channelLinkId, - ), - isNull(schema.chatSyncChannelLinksTable.deletedAt), - ), - ) - .innerJoin( - schema.chatSyncConnectionsTable, - and( - eq( - schema.chatSyncConnectionsTable.id, - schema.chatSyncChannelLinksTable.syncConnectionId, - ), - isNull(schema.chatSyncConnectionsTable.deletedAt), - ), - ) - .where( - and( - eq(schema.chatSyncMessageLinksTable.source, "external"), - isNull(schema.chatSyncMessageLinksTable.deletedAt), - eq(schema.chatSyncConnectionsTable.organizationId, data.organizationId), - eq(schema.chatSyncConnectionsTable.provider, data.provider), - ), - ) + const externalLinkedMessageIds = PG.from(schema.chatSyncMessageLinksTable, "message_link") + .innerJoin(schema.chatSyncChannelLinksTable, "channel_link", (link, channelLink) => + channelLink.id.eq(link.channelLinkId).and(channelLink.deletedAt.isNull()), + ) + .innerJoin(schema.chatSyncConnectionsTable, "connection", (_link, connection) => + connection.deletedAt.isNull(), + ) + .select(($) => ({ hazelMessageId: $.hazelMessageId })) + .where(($) => [ + $.connection.id.eq($.channel_link.syncConnectionId), + $.source.eq("external"), + $.deletedAt.isNull(), + $.connection.organizationId.eq(params.organizationId), + $.connection.provider.eq(params.provider), + ]) - const updatedMessages = await client - .update(schema.messagesTable) - .set({ - authorId: data.toAuthorId, - updatedAt: new Date(), - }) - .where( - and( - eq(schema.messagesTable.authorId, data.fromAuthorId), - isNull(schema.messagesTable.deletedAt), - inArray(schema.messagesTable.id, externalLinkedMessageIds), - ), - ) - .returning({ id: schema.messagesTable.id }) + const updatedMessages = yield* db.run( + PG.update(schema.messagesTable) + .set({ authorId: params.toAuthorId, updatedAt: new Date() }) + .where(($) => [ + $.authorId.eq(params.fromAuthorId), + $.deletedAt.isNull(), + PG.inSubquery($.id, externalLinkedMessageIds), + ]) + .returning("id"), + ) - return updatedMessages.length - }), - )(params, tx) + return updatedMessages.length + }) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/notification-repo.ts b/packages/backend-core/src/repositories/notification-repo.ts index 335d408c7..3d7d23379 100644 --- a/packages/backend-core/src/repositories/notification-repo.ts +++ b/packages/backend-core/src/repositories/notification-repo.ts @@ -1,4 +1,4 @@ -import { and, Database, eq, inArray, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { ChannelId, MessageId, OrganizationMemberId } from "@hazel/schema" import { Notification } from "@hazel/domain/models" @@ -25,26 +25,16 @@ export class NotificationRepo extends Context.Service()("Notif * ensures users can only delete their own notifications. * The caller is responsible for authorization. */ - const deleteByMessageIds = ( - messageIds: readonly MessageId[], - memberId: OrganizationMemberId, - tx?: TxFn, - ) => - db.makeQuery( - (execute, data: { messageIds: readonly MessageId[]; memberId: OrganizationMemberId }) => - execute((client) => - client - .delete(schema.notificationsTable) - .where( - and( - inArray(schema.notificationsTable.resourceId, [...data.messageIds]), - eq(schema.notificationsTable.resourceType, "message"), - eq(schema.notificationsTable.memberId, data.memberId), - ), - ) - .returning(), - ), - )({ messageIds, memberId }, tx) + const deleteByMessageIds = (messageIds: readonly MessageId[], memberId: OrganizationMemberId) => + db.run( + PG.deleteFrom(schema.notificationsTable) + .where(($) => [ + $.resourceId.in_(...messageIds), + $.resourceType.eq("message"), + $.memberId.eq(memberId), + ]) + .returning(), + ) /** * Delete all notifications for a specific channel and member. @@ -55,21 +45,16 @@ export class NotificationRepo extends Context.Service()("Notif * ensures users can only delete their own notifications. * The caller is responsible for authorization. */ - const deleteByChannelId = (channelId: ChannelId, memberId: OrganizationMemberId, tx?: TxFn) => - db.makeQuery((execute, data: { channelId: ChannelId; memberId: OrganizationMemberId }) => - execute((client) => - client - .delete(schema.notificationsTable) - .where( - and( - eq(schema.notificationsTable.targetedResourceId, data.channelId), - eq(schema.notificationsTable.targetedResourceType, "channel"), - eq(schema.notificationsTable.memberId, data.memberId), - ), - ) - .returning(), - ), - )({ channelId, memberId }, tx) + const deleteByChannelId = (channelId: ChannelId, memberId: OrganizationMemberId) => + db.run( + PG.deleteFrom(schema.notificationsTable) + .where(($) => [ + $.targetedResourceId.eq(channelId), + $.targetedResourceType.eq("channel"), + $.memberId.eq(memberId), + ]) + .returning(), + ) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/organization-member-repo.ts b/packages/backend-core/src/repositories/organization-member-repo.ts index 404300a34..850df322f 100644 --- a/packages/backend-core/src/repositories/organization-member-repo.ts +++ b/packages/backend-core/src/repositories/organization-member-repo.ts @@ -1,8 +1,14 @@ -import { and, count, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" import { OrganizationMember } from "@hazel/domain/models" -import { Context, Effect, Layer, Option, type Schema } from "effect" +import { Context, DateTime, Effect, Layer, type Schema } from "effect" + +/** Timestamp columns take a `Date`; domain models carry `Date | DateTime.Utc`. */ +const toDate = (value: Date | DateTime.Utc): Date => + DateTime.isDateTime(value) ? DateTime.toDate(value) : value +const toNullableDate = (value: Date | DateTime.Utc | null): Date | null => + value === null ? null : toDate(value) export class OrganizationMemberRepo extends Context.Service()( "OrganizationMemberRepo", @@ -19,143 +25,91 @@ export class OrganizationMemberRepo extends Context.Service - db - .makeQuery((execute, data: { organizationId: OrganizationId; userId: UserId }) => - execute((client) => - client - .select() - .from(schema.organizationMembersTable) - .where( - and( - eq( - schema.organizationMembersTable.organizationId, - data.organizationId, - ), - eq(schema.organizationMembersTable.userId, data.userId), - isNull(schema.organizationMembersTable.deletedAt), - ), - ) - .limit(1), - ), - )({ organizationId, userId }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByOrgAndUser = (organizationId: OrganizationId, userId: UserId) => + db.first( + PG.from(schema.organizationMembersTable) + .select() + .where(($) => [ + $.organizationId.eq(organizationId), + $.userId.eq(userId), + $.deletedAt.isNull(), + ]) + .limit(1), + ) - const upsertByOrgAndUser = ( - data: Schema.Schema.Type, - tx?: TxFn, - ) => - db.makeQuery((execute, input: typeof data) => - execute(async (client) => { - // Atomic upsert using onConflictDoUpdate to avoid race conditions - const result = await client - .insert(schema.organizationMembersTable) - .values(input as any) + const upsertByOrgAndUser = (data: Schema.Schema.Type) => + // Atomic upsert using onConflictDoUpdate to avoid race conditions + db + .run( + PG.insertInto(schema.organizationMembersTable) + .values({ + organizationId: data.organizationId, + userId: data.userId, + role: data.role, + nickname: data.nickname, + joinedAt: toDate(data.joinedAt), + invitedBy: data.invitedBy, + deletedAt: toNullableDate(data.deletedAt), + }) .onConflictDoUpdate({ - target: [ - schema.organizationMembersTable.organizationId, - schema.organizationMembersTable.userId, - ], + target: ["organizationId", "userId"], set: { - role: input.role, + role: data.role, deletedAt: null, }, }) - .returning() - return result[0] - }), - )(data, tx) + .returning(), + ) + .pipe(Effect.map((results) => results[0])) - const findAllByOrganization = (organizationId: OrganizationId, tx?: TxFn) => - db.makeQuery((execute, orgId: OrganizationId) => - execute((client) => - client - .select() - .from(schema.organizationMembersTable) - .where( - and( - eq(schema.organizationMembersTable.organizationId, orgId), - isNull(schema.organizationMembersTable.deletedAt), - ), - ), - ), - )(organizationId, tx) + const findAllByOrganization = (organizationId: OrganizationId) => + db.run( + PG.from(schema.organizationMembersTable) + .select() + .where(($) => [$.organizationId.eq(organizationId), $.deletedAt.isNull()]), + ) - const countByOrganization = (organizationId: OrganizationId, tx?: TxFn) => + const countByOrganization = (organizationId: OrganizationId) => db - .makeQuery((execute, orgId: OrganizationId) => - execute((client) => - client - .select({ count: count() }) - .from(schema.organizationMembersTable) - .where( - and( - eq(schema.organizationMembersTable.organizationId, orgId), - isNull(schema.organizationMembersTable.deletedAt), - ), - ), - ), - )(organizationId, tx) + .run( + PG.from(schema.organizationMembersTable) + .select(() => ({ count: PG.count() })) + .where(($) => [$.organizationId.eq(organizationId), $.deletedAt.isNull()]), + ) .pipe(Effect.map((results) => results[0]?.count ?? 0)) - const findAllActive = (tx?: TxFn) => - db.makeQuery((execute, _data: {}) => - execute((client) => - client - .select() - .from(schema.organizationMembersTable) - .where(isNull(schema.organizationMembersTable.deletedAt)), - ), - )({}, tx) + const findAllActive = () => + db.run( + PG.from(schema.organizationMembersTable) + .select() + .where(($) => [$.deletedAt.isNull()]), + ) - const softDelete = (id: OrganizationMemberId, tx?: TxFn) => - db.makeQuery((execute, memberId: OrganizationMemberId) => - execute((client) => - client - .update(schema.organizationMembersTable) - .set({ deletedAt: new Date() }) - .where( - and( - eq(schema.organizationMembersTable.id, memberId), - isNull(schema.organizationMembersTable.deletedAt), - ), - ), - ), - )(id, tx) + const softDelete = (id: OrganizationMemberId) => + db.run( + PG.update(schema.organizationMembersTable) + .set({ deletedAt: new Date() }) + .where(($) => [$.id.eq(id), $.deletedAt.isNull()]), + ) - const softDeleteByOrgAndUser = (organizationId: OrganizationId, userId: UserId, tx?: TxFn) => - db.makeQuery((execute, data: { organizationId: OrganizationId; userId: UserId }) => - execute((client) => - client - .update(schema.organizationMembersTable) - .set({ deletedAt: new Date() }) - .where( - and( - eq(schema.organizationMembersTable.organizationId, data.organizationId), - eq(schema.organizationMembersTable.userId, data.userId), - isNull(schema.organizationMembersTable.deletedAt), - ), - ), - ), - )({ organizationId, userId }, tx) + const softDeleteByOrgAndUser = (organizationId: OrganizationId, userId: UserId) => + db.run( + PG.update(schema.organizationMembersTable) + .set({ deletedAt: new Date() }) + .where(($) => [ + $.organizationId.eq(organizationId), + $.userId.eq(userId), + $.deletedAt.isNull(), + ]), + ) - const updateMetadata = (id: OrganizationMemberId, metadata: Record, tx?: TxFn) => - db - .makeQuery((execute, data: { id: OrganizationMemberId; metadata: Record }) => - execute((client) => - client - .update(schema.organizationMembersTable) - .set({ metadata: data.metadata }) - .where( - and( - eq(schema.organizationMembersTable.id, data.id), - isNull(schema.organizationMembersTable.deletedAt), - ), - ) - .returning(), - ), - )({ id, metadata }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const updateMetadata = (id: OrganizationMemberId, metadata: Record) => + db.first( + PG.update(schema.organizationMembersTable) + .set({ metadata }) + .where(($) => [$.id.eq(id), $.deletedAt.isNull()]) + .returning(), + ) const bulkUpsertByOrgAndUser = ( members: Schema.Schema.Type[], diff --git a/packages/backend-core/src/repositories/organization-repo.ts b/packages/backend-core/src/repositories/organization-repo.ts index 7df81aecf..c93cfa35a 100644 --- a/packages/backend-core/src/repositories/organization-repo.ts +++ b/packages/backend-core/src/repositories/organization-repo.ts @@ -1,7 +1,7 @@ -import { and, Database, eq, isNull, Repository, schema, sql, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { OrganizationId, UserId } from "@hazel/schema" import { Organization } from "@hazel/domain/models" -import { Context, Effect, Layer, Option, type Schema } from "effect" +import { Context, Effect, Layer, Option } from "effect" import { ChannelMemberRepo } from "./channel-member-repo" import { ChannelRepo } from "./channel-repo" @@ -19,53 +19,43 @@ export class OrganizationRepo extends Context.Service()("Organ const channelRepo = yield* ChannelRepo const channelMemberRepo = yield* ChannelMemberRepo - const findBySlug = (slug: string, tx?: TxFn) => - db - .makeQuery((execute, slugValue: string) => - execute((client) => - client - .select() - .from(schema.organizationsTable) - .where(eq(schema.organizationsTable.slug, slugValue)) - .limit(1), - ), - )(slug, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findBySlug = (slug: string) => + db.first( + PG.from(schema.organizationsTable) + .select() + .where(($) => [$.slug.eq(slug)]) + .limit(1), + ) // Find an organization by the Clerk org id stashed in `settings.clerkOrganizationId`. // Used by the Clerk webhook so post-migration events resolve to the canonical row // even when slugs diverge between WorkOS-era and Clerk. - const findByClerkOrgId = (clerkOrgId: string, tx?: TxFn) => - db - .makeQuery((execute, payload: { clerkOrgId: string }) => - execute((client) => - client - .select() - .from(schema.organizationsTable) - .where( - and( - sql`${schema.organizationsTable.settings}->>'clerkOrganizationId' = ${payload.clerkOrgId}`, - isNull(schema.organizationsTable.deletedAt), - ), - ) - .limit(1), - ), - )({ clerkOrgId }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByClerkOrgId = (clerkOrgId: string) => + db.first( + PG.from(schema.organizationsTable) + .select() + .where(($) => [ + PG.jsonText($.settings, "clerkOrganizationId").eq(clerkOrgId), + $.deletedAt.isNull(), + ]) + .limit(1), + ) // Resolve a Clerk organization to a local row, creating or back-linking as needed. // Lookup order is Clerk-ID first, slug second — this is the same order the Clerk // webhook uses, and it must stay aligned to prevent duplicate "ghost" rows from // being recreated by the session lazy-sync after a dedup pass. - const upsertFromClerk = ( - clerk: { id: string; name: string; slug: string | null; imageUrl?: string | null }, - tx?: TxFn, - ) => + const upsertFromClerk = (clerk: { + id: string + name: string + slug: string | null + imageUrl?: string | null + }) => Effect.gen(function* () { - const byClerkId = yield* findByClerkOrgId(clerk.id, tx).pipe(Effect.map(Option.getOrNull)) + const byClerkId = yield* findByClerkOrgId(clerk.id).pipe(Effect.map(Option.getOrNull)) const bySlug = !byClerkId && clerk.slug - ? yield* findBySlug(clerk.slug, tx).pipe(Effect.map(Option.getOrNull)) + ? yield* findBySlug(clerk.slug).pipe(Effect.map(Option.getOrNull)) : null const existing = byClerkId ?? bySlug @@ -78,22 +68,19 @@ export class OrganizationRepo extends Context.Service()("Organ const needsNameUpdate = clerk.name !== existing.name if (needsClerkIdLink || needsLogoUpdate || needsNameUpdate) { - yield* baseRepo.update( - { - id: existing.id, - ...(needsNameUpdate ? { name: clerk.name } : {}), - ...(needsLogoUpdate ? { logoUrl: desiredLogoUrl } : {}), - ...(needsClerkIdLink - ? { - settings: { - ...(currentSettings ?? {}), - clerkOrganizationId: clerk.id, - }, - } - : {}), - }, - tx, - ) + yield* baseRepo.update({ + id: existing.id, + ...(needsNameUpdate ? { name: clerk.name } : {}), + ...(needsLogoUpdate ? { logoUrl: desiredLogoUrl } : {}), + ...(needsClerkIdLink + ? { + settings: { + ...(currentSettings ?? {}), + clerkOrganizationId: clerk.id, + }, + } + : {}), + }) } return existing } @@ -101,64 +88,39 @@ export class OrganizationRepo extends Context.Service()("Organ if (!clerk.slug) return null const inserted = yield* baseRepo - .insert( - { - name: clerk.name, - slug: clerk.slug, - logoUrl: clerk.imageUrl ?? null, - isPublic: false, - settings: { clerkOrganizationId: clerk.id }, - deletedAt: null, - }, - tx, - ) + .insert({ + name: clerk.name, + slug: clerk.slug, + logoUrl: clerk.imageUrl ?? null, + isPublic: false, + settings: { clerkOrganizationId: clerk.id }, + deletedAt: null, + }) .pipe(Effect.map((rows) => rows[0]!)) return inserted }) - const findBySlugIfPublic = (slug: string, tx?: TxFn) => - db - .makeQuery((execute, slugValue: string) => - execute((client) => - client - .select() - .from(schema.organizationsTable) - .where( - and( - eq(schema.organizationsTable.slug, slugValue), - eq(schema.organizationsTable.isPublic, true), - isNull(schema.organizationsTable.deletedAt), - ), - ) - .limit(1), - ), - )(slug, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findBySlugIfPublic = (slug: string) => + db.first( + PG.from(schema.organizationsTable) + .select() + .where(($) => [$.slug.eq(slug), $.isPublic.eq(true), $.deletedAt.isNull()]) + .limit(1), + ) - const findAllActive = (tx?: TxFn) => - db.makeQuery((execute, _data: {}) => - execute((client) => - client - .select() - .from(schema.organizationsTable) - .where(isNull(schema.organizationsTable.deletedAt)), - ), - )({}, tx) + const findAllActive = () => + db.run( + PG.from(schema.organizationsTable) + .select() + .where(($) => [$.deletedAt.isNull()]), + ) - const softDelete = (id: OrganizationId, tx?: TxFn) => - db.makeQuery((execute, orgId: OrganizationId) => - execute((client) => - client - .update(schema.organizationsTable) - .set({ deletedAt: new Date() }) - .where( - and( - eq(schema.organizationsTable.id, orgId), - isNull(schema.organizationsTable.deletedAt), - ), - ), - ), - )(id, tx) + const softDelete = (id: OrganizationId) => + db.run( + PG.update(schema.organizationsTable) + .set({ deletedAt: new Date() }) + .where(($) => [$.id.eq(id), $.deletedAt.isNull()]), + ) const setupDefaultChannels = (organizationId: OrganizationId, userId: UserId) => Effect.gen(function* () { diff --git a/packages/backend-core/src/repositories/rss-subscription-repo.ts b/packages/backend-core/src/repositories/rss-subscription-repo.ts index 7aa6e83ec..64d54f3a6 100644 --- a/packages/backend-core/src/repositories/rss-subscription-repo.ts +++ b/packages/backend-core/src/repositories/rss-subscription-repo.ts @@ -1,8 +1,8 @@ -import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { ChannelId, OrganizationId, RssSubscriptionId } from "@hazel/schema" import { RssSubscription } from "@hazel/domain/models" -import { Context, Effect, Layer, Option } from "effect" +import { Context, Effect, Layer } from "effect" export class RssSubscriptionRepo extends Context.Service()("RssSubscriptionRepo", { make: Effect.gen(function* () { @@ -17,56 +17,29 @@ export class RssSubscriptionRepo extends Context.Service()( const db = yield* Database.Database // Find all subscriptions for a channel - const findByChannel = (channelId: ChannelId, tx?: TxFn) => - db.makeQuery((execute, data: { channelId: ChannelId }) => - execute((client) => - client - .select() - .from(schema.rssSubscriptionsTable) - .where( - and( - eq(schema.rssSubscriptionsTable.channelId, data.channelId), - isNull(schema.rssSubscriptionsTable.deletedAt), - ), - ), - ), - )({ channelId }, tx) + const findByChannel = (channelId: ChannelId) => + db.run( + PG.from(schema.rssSubscriptionsTable) + .select() + .where(($) => [$.channelId.eq(channelId), $.deletedAt.isNull()]), + ) // Find all subscriptions for an organization - const findByOrganization = (organizationId: OrganizationId, tx?: TxFn) => - db.makeQuery((execute, data: { organizationId: OrganizationId }) => - execute((client) => - client - .select() - .from(schema.rssSubscriptionsTable) - .where( - and( - eq(schema.rssSubscriptionsTable.organizationId, data.organizationId), - isNull(schema.rssSubscriptionsTable.deletedAt), - ), - ), - ), - )({ organizationId }, tx) + const findByOrganization = (organizationId: OrganizationId) => + db.run( + PG.from(schema.rssSubscriptionsTable) + .select() + .where(($) => [$.organizationId.eq(organizationId), $.deletedAt.isNull()]), + ) // Find subscription by channel and feed URL (for uniqueness check) - const findByChannelAndFeedUrl = (channelId: ChannelId, feedUrl: string, tx?: TxFn) => - db - .makeQuery((execute, data: { channelId: ChannelId; feedUrl: string }) => - execute((client) => - client - .select() - .from(schema.rssSubscriptionsTable) - .where( - and( - eq(schema.rssSubscriptionsTable.channelId, data.channelId), - eq(schema.rssSubscriptionsTable.feedUrl, data.feedUrl), - isNull(schema.rssSubscriptionsTable.deletedAt), - ), - ) - .limit(1), - ), - )({ channelId, feedUrl }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByChannelAndFeedUrl = (channelId: ChannelId, feedUrl: string) => + db.first( + PG.from(schema.rssSubscriptionsTable) + .select() + .where(($) => [$.channelId.eq(channelId), $.feedUrl.eq(feedUrl), $.deletedAt.isNull()]) + .limit(1), + ) // Update subscription settings const updateSettings = ( @@ -75,46 +48,31 @@ export class RssSubscriptionRepo extends Context.Service()( isEnabled?: boolean pollingIntervalMinutes?: number }, - tx?: TxFn, ) => - db.makeQuery( - ( - execute, - data: { - id: RssSubscriptionId - isEnabled?: boolean - pollingIntervalMinutes?: number - }, - ) => - execute((client) => - client - .update(schema.rssSubscriptionsTable) - .set({ - ...(data.isEnabled !== undefined && { isEnabled: data.isEnabled }), - ...(data.pollingIntervalMinutes !== undefined && { - pollingIntervalMinutes: data.pollingIntervalMinutes, - }), - updatedAt: new Date(), - }) - .where(eq(schema.rssSubscriptionsTable.id, data.id)) - .returning(), - ), - )({ id, ...settings }, tx) + db.run( + PG.update(schema.rssSubscriptionsTable) + .set({ + ...(settings.isEnabled !== undefined && { isEnabled: settings.isEnabled }), + ...(settings.pollingIntervalMinutes !== undefined && { + pollingIntervalMinutes: settings.pollingIntervalMinutes, + }), + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) // Soft delete subscription - const softDelete = (id: RssSubscriptionId, tx?: TxFn) => - db.makeQuery((execute, data: { id: RssSubscriptionId }) => - execute((client) => - client - .update(schema.rssSubscriptionsTable) - .set({ - deletedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(schema.rssSubscriptionsTable.id, data.id)) - .returning(), - ), - )({ id }, tx) + const softDelete = (id: RssSubscriptionId) => + db.run( + PG.update(schema.rssSubscriptionsTable) + .set({ + deletedAt: new Date(), + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(id)]) + .returning(), + ) return { ...baseRepo, diff --git a/packages/backend-core/src/repositories/typing-indicator-repo.ts b/packages/backend-core/src/repositories/typing-indicator-repo.ts index 8120d91c0..03169c86b 100644 --- a/packages/backend-core/src/repositories/typing-indicator-repo.ts +++ b/packages/backend-core/src/repositories/typing-indicator-repo.ts @@ -1,6 +1,6 @@ -import { and, Database, eq, lt, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" -import { ChannelId, ChannelMemberId, TypingIndicatorId } from "@hazel/schema" +import { type ChannelId, type ChannelMemberId, TypingIndicatorId } from "@hazel/schema" import { TypingIndicator } from "@hazel/domain/models" import { Context, Effect, Layer } from "effect" @@ -17,70 +17,49 @@ export class TypingIndicatorRepo extends Context.Service()( ) // Add custom method to delete by channel and member - const deleteByChannelAndMember = ( - { - channelId, - memberId, - }: { - channelId: ChannelId - memberId: ChannelMemberId - }, - tx?: TxFn, - ) => - db.makeQuery((execute, _data) => - execute((client) => - client - .delete(schema.typingIndicatorsTable) - .where( - and( - eq(schema.typingIndicatorsTable.channelId, channelId), - eq(schema.typingIndicatorsTable.memberId, memberId), - ), - ), - ), - )({ channelId, memberId }, tx) + const deleteByChannelAndMember = ({ + channelId, + memberId, + }: { + channelId: ChannelId + memberId: ChannelMemberId + }) => + db.run( + PG.deleteFrom(schema.typingIndicatorsTable).where(($) => [ + $.channelId.eq(channelId), + $.memberId.eq(memberId), + ]), + ) // Upsert method to create or update typing indicator - const upsertByChannelAndMember = ( - params: { - channelId: ChannelId - memberId: ChannelMemberId - lastTyped: number - }, - tx?: TxFn, - ) => - db.makeQuery((execute, _data) => - execute((client) => { - return client - .insert(schema.typingIndicatorsTable) - .values({ - id: TypingIndicatorId.make(crypto.randomUUID()), - channelId: params.channelId, - memberId: params.memberId, - lastTyped: params.lastTyped, - }) - .onConflictDoUpdate({ - target: [ - schema.typingIndicatorsTable.channelId, - schema.typingIndicatorsTable.memberId, - ], - set: { lastTyped: params.lastTyped }, - }) - .returning() - }), - )(params, tx) + const upsertByChannelAndMember = (params: { + channelId: ChannelId + memberId: ChannelMemberId + lastTyped: number + }) => + db.run( + PG.insertInto(schema.typingIndicatorsTable) + .values({ + id: TypingIndicatorId.make(crypto.randomUUID()), + channelId: params.channelId, + memberId: params.memberId, + lastTyped: params.lastTyped, + }) + .onConflictDoUpdate({ + target: ["channelId", "memberId"], + set: { lastTyped: params.lastTyped }, + }) + .returning(), + ) // Cleanup method to remove stale indicators - const deleteStale = (thresholdMs: number = 10000, tx?: TxFn) => { + const deleteStale = (thresholdMs: number = 10000) => { const threshold = Date.now() - thresholdMs - return db.makeQuery((execute, _data) => - execute((client) => - client - .delete(schema.typingIndicatorsTable) - .where(lt(schema.typingIndicatorsTable.lastTyped, threshold)) - .returning(), - ), - )({}, tx) + return db.run( + PG.deleteFrom(schema.typingIndicatorsTable) + .where(($) => [$.lastTyped.lt(threshold)]) + .returning(), + ) } return { diff --git a/packages/backend-core/src/repositories/user-presence-status-repo.ts b/packages/backend-core/src/repositories/user-presence-status-repo.ts index 0f8da9e47..7c0e7e664 100644 --- a/packages/backend-core/src/repositories/user-presence-status-repo.ts +++ b/packages/backend-core/src/repositories/user-presence-status-repo.ts @@ -1,8 +1,14 @@ -import { and, Database, eq, inArray, lt, Repository, ne, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { ChannelId, UserId } from "@hazel/schema" import { UserPresenceStatus } from "@hazel/domain/models" -import { Context, Effect, Layer, Option, type Schema } from "effect" +import { Context, DateTime, Effect, Layer, type Schema } from "effect" + +/** Timestamp columns take a `Date`; domain models carry `Date | DateTime.Utc`. */ +const toDate = (value: Date | DateTime.Utc): Date => + DateTime.isDateTime(value) ? DateTime.toDate(value) : value +const toNullableDate = (value: Date | DateTime.Utc | null): Date | null => + value === null ? null : toDate(value) export class UserPresenceStatusRepo extends Context.Service()( "UserPresenceStatusRepo", @@ -19,132 +25,103 @@ export class UserPresenceStatusRepo extends Context.Service - db - .makeQuery((execute, _data) => - execute((client) => - client - .select() - .from(schema.userPresenceStatusTable) - .where(eq(schema.userPresenceStatusTable.userId, userId)) - .limit(1), - ), - )({ userId }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByUserId = (userId: UserId) => + db.first( + PG.from(schema.userPresenceStatusTable) + .select() + .where(($) => [$.userId.eq(userId)]) + .limit(1), + ) // Upsert user presence status - const upsertByUserId = (data: Schema.Schema.Type, tx?: TxFn) => + const upsertByUserId = (data: Schema.Schema.Type) => db - .makeQuery((execute, input: typeof data) => - execute((client) => - client - .insert(schema.userPresenceStatusTable) - .values(input as any) - .onConflictDoUpdate({ - target: schema.userPresenceStatusTable.userId, - set: { - status: input.status, - customMessage: input.customMessage, - statusEmoji: input.statusEmoji, - statusExpiresAt: input.statusExpiresAt as any, - activeChannelId: input.activeChannelId, - updatedAt: new Date(), - lastSeenAt: new Date(), - }, - }) - .returning(), - ), - )(data, tx) + .run( + PG.insertInto(schema.userPresenceStatusTable) + .values({ + userId: data.userId, + status: data.status, + customMessage: data.customMessage, + statusEmoji: data.statusEmoji, + statusExpiresAt: toNullableDate(data.statusExpiresAt), + activeChannelId: data.activeChannelId, + suppressNotifications: data.suppressNotifications, + updatedAt: toDate(data.updatedAt), + lastSeenAt: toDate(data.lastSeenAt), + }) + .onConflictDoUpdate({ + target: ["userId"], + set: { + status: data.status, + customMessage: data.customMessage, + statusEmoji: data.statusEmoji, + statusExpiresAt: toNullableDate(data.statusExpiresAt), + activeChannelId: data.activeChannelId, + updatedAt: new Date(), + lastSeenAt: new Date(), + }, + }) + .returning(), + ) .pipe(Effect.map((results) => results[0])) // Update active channel for user - const updateActiveChannel = ( - params: { - userId: UserId - activeChannelId: ChannelId | null - }, - tx?: TxFn, - ) => - db.makeQuery((execute, _data) => - execute((client) => - client - .update(schema.userPresenceStatusTable) - .set({ - activeChannelId: params.activeChannelId, - updatedAt: new Date(), - }) - .where(eq(schema.userPresenceStatusTable.userId, params.userId)) - .returning(), - ), - )(params, tx) + const updateActiveChannel = (params: { userId: UserId; activeChannelId: ChannelId | null }) => + db.run( + PG.update(schema.userPresenceStatusTable) + .set({ + activeChannelId: params.activeChannelId, + updatedAt: new Date(), + }) + .where(($) => [$.userId.eq(params.userId)]) + .returning(), + ) // Update user status - const updateStatus = ( - params: { - userId: UserId - status: "online" | "away" | "busy" | "dnd" | "offline" - customMessage?: string | null - }, - tx?: TxFn, - ) => - db.makeQuery((execute, _data) => - execute((client) => - client - .update(schema.userPresenceStatusTable) - .set({ - status: params.status, - customMessage: params.customMessage, - updatedAt: new Date(), - }) - .where(eq(schema.userPresenceStatusTable.userId, params.userId)) - .returning(), - ), - )(params, tx) + const updateStatus = (params: { + userId: UserId + status: "online" | "away" | "busy" | "dnd" | "offline" + customMessage?: string | null + }) => + db.run( + PG.update(schema.userPresenceStatusTable) + .set({ + status: params.status, + customMessage: params.customMessage, + updatedAt: new Date(), + }) + .where(($) => [$.userId.eq(params.userId)]) + .returning(), + ) // Update heartbeat timestamp (lightweight operation for presence tracking) - const updateHeartbeat = (userId: UserId, tx?: TxFn) => - db - .makeQuery((execute, _data) => - execute((client) => - client - .update(schema.userPresenceStatusTable) - .set({ - lastSeenAt: new Date(), - }) - .where(eq(schema.userPresenceStatusTable.userId, userId)) - .returning(), - ), - )({ userId }, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const updateHeartbeat = (userId: UserId) => + db.first( + PG.update(schema.userPresenceStatusTable) + .set({ + lastSeenAt: new Date(), + }) + .where(($) => [$.userId.eq(userId)]) + .returning(), + ) // Find users with stale heartbeats (for cron job cleanup) const findStaleUsers = (timeout: Date) => - db.execute((client) => - client - .select({ - userId: schema.userPresenceStatusTable.userId, - status: schema.userPresenceStatusTable.status, - lastSeenAt: schema.userPresenceStatusTable.lastSeenAt, - }) - .from(schema.userPresenceStatusTable) - .where( - and( - lt(schema.userPresenceStatusTable.lastSeenAt, timeout), - ne(schema.userPresenceStatusTable.status, "offline"), - ), - ), + db.run( + PG.from(schema.userPresenceStatusTable) + .select("userId", "status", "lastSeenAt") + .where(($) => [$.lastSeenAt.lt(timeout), $.status.neq("offline")]), ) // Batch update users to offline status const markUsersOffline = (userIds: UserId[]) => - db.execute((client) => - client - .update(schema.userPresenceStatusTable) + db.run( + PG.update(schema.userPresenceStatusTable) .set({ status: "offline", updatedAt: new Date(), }) - .where(inArray(schema.userPresenceStatusTable.userId, userIds)) + .where(($) => [$.userId.in_(...userIds)]) .returning(), ) diff --git a/packages/backend-core/src/repositories/user-repo.ts b/packages/backend-core/src/repositories/user-repo.ts index 9d7768ecc..1072c6f71 100644 --- a/packages/backend-core/src/repositories/user-repo.ts +++ b/packages/backend-core/src/repositories/user-repo.ts @@ -1,8 +1,12 @@ -import { and, Database, eq, isNull, Repository, schema, type TxFn } from "@hazel/db" +import { Database, PG, Repository, schema } from "@hazel/db" import type { ClerkUserId, UserId } from "@hazel/schema" import { User } from "@hazel/domain/models" -import { Context, Effect, Layer, Option, type Schema } from "effect" +import { Context, DateTime, Effect, Layer, type Schema } from "effect" + +/** Timestamp columns take a `Date`; domain models carry `Date | DateTime.Utc`. */ +const toNullableDate = (value: Date | DateTime.Utc | null): Date | null => + value === null ? null : DateTime.isDateTime(value) ? DateTime.toDate(value) : value export class UserRepo extends Context.Service()("UserRepo", { make: Effect.gen(function* () { @@ -16,18 +20,13 @@ export class UserRepo extends Context.Service()("UserRepo", { ) const db = yield* Database.Database - const findByExternalId = (externalId: string, tx?: TxFn) => - db - .makeQuery((execute, id: string) => - execute((client) => - client - .select() - .from(schema.usersTable) - .where(eq(schema.usersTable.externalId, id)) - .limit(1), - ), - )(externalId, tx) - .pipe(Effect.map((results) => Option.fromNullishOr(results[0]))) + const findByExternalId = (externalId: string) => + db.first( + PG.from(schema.usersTable) + .select() + .where(($) => [$.externalId.eq(externalId)]) + .limit(1), + ) /** * Upsert user by external ID. @@ -37,66 +36,63 @@ export class UserRepo extends Context.Service()("UserRepo", { const upsertByExternalId = ( data: Schema.Schema.Type, options?: { syncAvatarUrl?: boolean }, - tx?: TxFn, ) => db - .makeQuery((execute, input: typeof data & { syncAvatarUrl?: boolean }) => - execute((client) => - client - .insert(schema.usersTable) - .values(input as any) - .onConflictDoUpdate({ - target: schema.usersTable.externalId, - set: { - firstName: input.firstName, - lastName: input.lastName, - ...(input.syncAvatarUrl && { avatarUrl: input.avatarUrl }), - email: input.email, - updatedAt: new Date(), - }, - }) - .returning(), - ), - )({ ...data, syncAvatarUrl: options?.syncAvatarUrl }, tx) + .run( + PG.insertInto(schema.usersTable) + .values({ + externalId: data.externalId, + email: data.email, + firstName: data.firstName, + lastName: data.lastName, + avatarUrl: data.avatarUrl, + userType: data.userType, + settings: data.settings, + isOnboarded: data.isOnboarded, + timezone: data.timezone, + deletedAt: toNullableDate(data.deletedAt), + }) + .onConflictDoUpdate({ + target: ["externalId"], + set: { + firstName: data.firstName, + lastName: data.lastName, + ...(options?.syncAvatarUrl && { avatarUrl: data.avatarUrl }), + email: data.email, + updatedAt: new Date(), + }, + }) + .returning(), + ) .pipe(Effect.map((results) => results[0])) const upsertClerkUser = ( data: Omit, "externalId"> & { externalId: ClerkUserId }, options?: { syncAvatarUrl?: boolean }, - tx?: TxFn, - ) => upsertByExternalId(data, options, tx) + ) => upsertByExternalId(data, options) - const findAllActive = (tx?: TxFn) => - db.makeQuery((execute, _data: {}) => - execute((client) => - client.select().from(schema.usersTable).where(isNull(schema.usersTable.deletedAt)), - ), - )({}, tx) + const findAllActive = () => + db.run( + PG.from(schema.usersTable) + .select() + .where(($) => [$.deletedAt.isNull()]), + ) - const softDelete = (id: UserId, tx?: TxFn) => - db.makeQuery((execute, userId: UserId) => - execute((client) => - client - .update(schema.usersTable) - .set({ deletedAt: new Date() }) - .where(and(eq(schema.usersTable.id, userId), isNull(schema.usersTable.deletedAt))), - ), - )(id, tx) + const softDelete = (id: UserId) => + db.run( + PG.update(schema.usersTable) + .set({ deletedAt: new Date() }) + .where(($) => [$.id.eq(id), $.deletedAt.isNull()]), + ) - const softDeleteByExternalId = (externalId: string, tx?: TxFn) => - db.makeQuery((execute, id: string) => - execute((client) => - client - .update(schema.usersTable) - .set({ deletedAt: new Date() }) - .where( - and(eq(schema.usersTable.externalId, id), isNull(schema.usersTable.deletedAt)), - ), - ), - )(externalId, tx) + const softDeleteByExternalId = (externalId: string) => + db.run( + PG.update(schema.usersTable) + .set({ deletedAt: new Date() }) + .where(($) => [$.externalId.eq(externalId), $.deletedAt.isNull()]), + ) - const softDeleteByClerkUserId = (clerkUserId: ClerkUserId, tx?: TxFn) => - softDeleteByExternalId(clerkUserId, tx) + const softDeleteByClerkUserId = (clerkUserId: ClerkUserId) => softDeleteByExternalId(clerkUserId) const bulkUpsertByExternalId = (users: Schema.Schema.Type[]) => Effect.forEach(users, (data) => upsertByExternalId(data), { concurrency: 10 }) diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts deleted file mode 100644 index b90ec94a0..000000000 --- a/packages/db/drizzle.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig } from "drizzle-kit" - -export default defineConfig({ - dialect: "postgresql", - schema: "./src/schema/index.ts", - out: "./drizzle", - dbCredentials: { - url: process.env.DATABASE_URL!, - }, - verbose: true, - strict: true, - migrations: { - table: "drizzle_migrations", - schema: "public", - }, -}) diff --git a/packages/db/effect-orm.config.ts b/packages/db/effect-orm.config.ts new file mode 100644 index 000000000..5a9712c58 --- /dev/null +++ b/packages/db/effect-orm.config.ts @@ -0,0 +1,23 @@ +import * as PgClient from "@effect/sql-pg/PgClient" +import { defineConfig } from "@maple-dev/effect-orm/kit" +import * as Migrate from "@maple-dev/effect-orm/migrate" +import { Layer, Redacted } from "effect" + +export default defineConfig({ + dialect: "postgres", + schema: "./src/tables/index.ts", + // `_/migration.sql`, the layout `db:migrate` applies. + out: "./migrations", + emit: "sql", + // `migrate`, `status`, `verify` and `baseline` run against DATABASE_URL (local docker Postgres by default). + driver: Migrate.layerSqlClient().pipe( + Layer.provide( + PgClient.layer({ + url: Redacted.make( + process.env.DATABASE_URL ?? "postgresql://user:password@localhost:5432/app", + ), + maxConnections: 1, + }), + ), + ), +}) diff --git a/packages/db/migrations/20261012000000_initial_schema/migration.sql b/packages/db/migrations/20261012000000_initial_schema/migration.sql new file mode 100644 index 000000000..7ad78a528 --- /dev/null +++ b/packages/db/migrations/20261012000000_initial_schema/migration.sql @@ -0,0 +1,856 @@ +CREATE TYPE public.attachment_status AS ENUM ('uploading', 'complete', 'failed'); +--> statement-breakpoint +CREATE TYPE public.channel_type AS ENUM ('public', 'private', 'thread', 'direct', 'single'); +--> statement-breakpoint +CREATE TYPE public.chat_sync_connection_status AS ENUM ('active', 'paused', 'error', 'disabled'); +--> statement-breakpoint +CREATE TYPE public.chat_sync_direction AS ENUM ('both', 'hazel_to_external', 'external_to_hazel'); +--> statement-breakpoint +CREATE TYPE public.chat_sync_receipt_source AS ENUM ('hazel', 'external'); +--> statement-breakpoint +CREATE TYPE public.chat_sync_receipt_status AS ENUM ('processed', 'ignored', 'failed'); +--> statement-breakpoint +CREATE TYPE public.connect_conversation_channel_role AS ENUM ('host', 'guest'); +--> statement-breakpoint +CREATE TYPE public.connect_conversation_status AS ENUM ('active', 'disconnected'); +--> statement-breakpoint +CREATE TYPE public.connect_invite_status AS ENUM ('pending', 'accepted', 'declined', 'revoked', 'expired'); +--> statement-breakpoint +CREATE TYPE public.connect_invite_target_kind AS ENUM ('slug', 'email'); +--> statement-breakpoint +CREATE TYPE public.connection_level AS ENUM ('organization', 'user'); +--> statement-breakpoint +CREATE TYPE public.connection_status AS ENUM ('active', 'expired', 'revoked', 'error', 'suspended'); +--> statement-breakpoint +CREATE TYPE public.integration_request_status AS ENUM ('pending', 'reviewed', 'planned', 'rejected'); +--> statement-breakpoint +CREATE TYPE public.message_outbox_event_status AS ENUM ('pending', 'processing', 'processed', 'failed'); +--> statement-breakpoint +CREATE TYPE public.message_outbox_event_type AS ENUM ('message_created', 'message_updated', 'message_deleted', 'reaction_created', 'reaction_deleted'); +--> statement-breakpoint +CREATE TYPE public.organization_role AS ENUM ('admin', 'member', 'owner'); +--> statement-breakpoint +CREATE TYPE public.user_presence_status_enum AS ENUM ('online', 'away', 'busy', 'dnd', 'offline'); +--> statement-breakpoint +CREATE TYPE public.user_type AS ENUM ('user', 'machine'); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "attachments" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "organizationId" uuid NOT NULL, + "channelId" uuid, + "messageId" uuid, + "fileName" character varying(255) NOT NULL, + "fileSize" integer NOT NULL, + "externalUrl" character varying(2048), + "uploadedBy" uuid NOT NULL, + "status" public.attachment_status NOT NULL DEFAULT 'uploading', + "uploadedAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "attachments_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "bot_commands" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "botId" uuid NOT NULL, + "name" character varying(50) NOT NULL, + "description" text NOT NULL, + "arguments" jsonb DEFAULT '[]', + "usageExample" text, + "isEnabled" boolean NOT NULL DEFAULT true, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "bot_commands_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "bot_installations" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "botId" uuid NOT NULL, + "organizationId" uuid NOT NULL, + "installedBy" uuid NOT NULL, + "installedAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "bot_installations_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "bots" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "createdBy" uuid NOT NULL, + "name" character varying(100) NOT NULL, + "description" text, + "webhookUrl" text, + "apiTokenHash" text NOT NULL, + "scopes" jsonb, + "metadata" jsonb, + "isPublic" boolean NOT NULL DEFAULT false, + "installCount" integer NOT NULL DEFAULT 0, + "mentionable" boolean NOT NULL DEFAULT false, + "allowedIntegrations" jsonb DEFAULT '[]', + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "bots_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "channel_access" ( + "userId" uuid NOT NULL, + "channelId" uuid NOT NULL, + "organizationId" uuid NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "channel_access_user_channel_pk" PRIMARY KEY ("userId", "channelId") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "channel_members" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "channelId" uuid NOT NULL, + "userId" uuid NOT NULL, + "isHidden" boolean NOT NULL DEFAULT false, + "isMuted" boolean NOT NULL DEFAULT false, + "isFavorite" boolean NOT NULL DEFAULT false, + "lastSeenMessageId" uuid, + "notificationCount" integer NOT NULL DEFAULT 0, + "joinedAt" timestamp with time zone NOT NULL DEFAULT now(), + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "channel_members_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "channel_sections" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "organizationId" uuid NOT NULL, + "name" character varying(255) NOT NULL, + "order" integer NOT NULL DEFAULT 0, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "channel_sections_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "channel_webhooks" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "channelId" uuid NOT NULL, + "organizationId" uuid NOT NULL, + "botUserId" uuid NOT NULL, + "name" character varying(100) NOT NULL, + "description" text, + "avatarUrl" text, + "tokenHash" text NOT NULL, + "tokenSuffix" character varying(4) NOT NULL, + "isEnabled" boolean NOT NULL DEFAULT true, + "createdBy" uuid NOT NULL, + "lastUsedAt" timestamp with time zone, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "channel_webhooks_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "channels" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "name" character varying(255) NOT NULL, + "icon" character varying(32), + "type" public.channel_type NOT NULL, + "organizationId" uuid NOT NULL, + "parentChannelId" uuid, + "sectionId" uuid, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "channels_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "chat_sync_channel_links" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "syncConnectionId" uuid NOT NULL, + "hazelChannelId" uuid NOT NULL, + "externalChannelId" character varying(255) NOT NULL, + "externalChannelName" character varying(255), + "direction" public.chat_sync_direction NOT NULL DEFAULT 'both', + "isActive" boolean NOT NULL DEFAULT true, + "settings" jsonb, + "lastSyncedAt" timestamp with time zone, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "chat_sync_channel_links_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "chat_sync_connections" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "organizationId" uuid NOT NULL, + "integrationConnectionId" uuid, + "provider" character varying(50) NOT NULL, + "externalWorkspaceId" character varying(255) NOT NULL, + "externalWorkspaceName" character varying(255), + "status" public.chat_sync_connection_status NOT NULL DEFAULT 'active', + "settings" jsonb, + "metadata" jsonb, + "errorMessage" text, + "lastSyncedAt" timestamp with time zone, + "createdBy" uuid NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "chat_sync_connections_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "chat_sync_event_receipts" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "syncConnectionId" uuid NOT NULL, + "channelLinkId" uuid, + "source" public.chat_sync_receipt_source NOT NULL, + "externalEventId" character varying(255), + "dedupeKey" character varying(255) NOT NULL, + "payloadHash" character varying(128), + "status" public.chat_sync_receipt_status NOT NULL DEFAULT 'processed', + "errorMessage" text, + "processedAt" timestamp with time zone NOT NULL DEFAULT now(), + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "chat_sync_event_receipts_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "chat_sync_message_links" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "channelLinkId" uuid NOT NULL, + "hazelMessageId" uuid NOT NULL, + "externalMessageId" character varying(255) NOT NULL, + "source" public.chat_sync_receipt_source NOT NULL DEFAULT 'hazel', + "rootHazelMessageId" uuid, + "rootExternalMessageId" character varying(255), + "hazelThreadChannelId" uuid, + "externalThreadId" character varying(255), + "lastSyncedAt" timestamp with time zone NOT NULL DEFAULT now(), + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "chat_sync_message_links_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "connect_conversation_channels" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "conversationId" uuid NOT NULL, + "organizationId" uuid NOT NULL, + "channelId" uuid NOT NULL, + "role" public.connect_conversation_channel_role NOT NULL, + "allowGuestMemberAdds" boolean NOT NULL DEFAULT false, + "isActive" boolean NOT NULL DEFAULT true, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "connect_conversation_channels_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "connect_conversations" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "hostOrganizationId" uuid NOT NULL, + "hostChannelId" uuid NOT NULL, + "status" public.connect_conversation_status NOT NULL DEFAULT 'active', + "settings" jsonb, + "createdBy" uuid NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "connect_conversations_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "connect_invites" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "conversationId" uuid NOT NULL, + "hostOrganizationId" uuid NOT NULL, + "hostChannelId" uuid NOT NULL, + "targetKind" public.connect_invite_target_kind NOT NULL, + "targetValue" character varying(255) NOT NULL, + "guestOrganizationId" uuid, + "status" public.connect_invite_status NOT NULL DEFAULT 'pending', + "allowGuestMemberAdds" boolean NOT NULL DEFAULT false, + "invitedBy" uuid NOT NULL, + "acceptedBy" uuid, + "acceptedAt" timestamp with time zone, + "expiresAt" timestamp with time zone, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "connect_invites_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "connect_participants" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "conversationId" uuid NOT NULL, + "channelId" uuid NOT NULL, + "userId" uuid NOT NULL, + "homeOrganizationId" uuid NOT NULL, + "isExternal" boolean NOT NULL DEFAULT false, + "addedBy" uuid, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "connect_participants_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "custom_emojis" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "organizationId" uuid NOT NULL, + "name" character varying(64) NOT NULL, + "imageUrl" text NOT NULL, + "createdBy" uuid NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone, + "deletedAt" timestamp with time zone, + CONSTRAINT "custom_emojis_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "github_subscriptions" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "channelId" uuid NOT NULL, + "organizationId" uuid NOT NULL, + "repositoryId" bigint NOT NULL, + "repositoryFullName" character varying(255) NOT NULL, + "repositoryOwner" character varying(255) NOT NULL, + "repositoryName" character varying(255) NOT NULL, + "enabledEvents" jsonb NOT NULL DEFAULT '["push","pull_request","issues"]', + "branchFilter" character varying(255), + "isEnabled" boolean NOT NULL DEFAULT true, + "createdBy" uuid NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "github_subscriptions_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "integration_connections" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "provider" character varying(50) NOT NULL, + "organizationId" uuid NOT NULL, + "userId" uuid, + "level" public.connection_level NOT NULL, + "status" public.connection_status NOT NULL DEFAULT 'active', + "externalAccountId" character varying(255), + "externalAccountName" character varying(255), + "connectedBy" uuid NOT NULL, + "settings" jsonb, + "metadata" jsonb, + "errorMessage" text, + "lastUsedAt" timestamp with time zone, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "integration_connections_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "integration_requests" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "organizationId" uuid NOT NULL, + "requestedBy" uuid NOT NULL, + "integrationName" character varying(255) NOT NULL, + "integrationUrl" character varying(500), + "description" text, + "status" public.integration_request_status NOT NULL DEFAULT 'pending', + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "integration_requests_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "integration_tokens" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "connectionId" uuid NOT NULL, + "encryptedAccessToken" text NOT NULL, + "encryptedRefreshToken" text, + "iv" character varying(32) NOT NULL, + "refreshTokenIv" character varying(32), + "encryptionKeyVersion" integer NOT NULL DEFAULT 1, + "tokenType" character varying(50) DEFAULT 'Bearer', + "scope" text, + "expiresAt" timestamp with time zone, + "refreshTokenExpiresAt" timestamp with time zone, + "lastRefreshedAt" timestamp with time zone, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "integration_tokens_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "message_outbox_events" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "sequence" bigint NOT NULL GENERATED ALWAYS AS IDENTITY, + "eventType" public.message_outbox_event_type NOT NULL, + "aggregateId" uuid NOT NULL, + "channelId" uuid NOT NULL, + "payload" jsonb NOT NULL, + "status" public.message_outbox_event_status NOT NULL DEFAULT 'pending', + "attemptCount" integer NOT NULL DEFAULT 0, + "availableAt" timestamp with time zone NOT NULL DEFAULT now(), + "lockedAt" timestamp with time zone, + "lockedBy" character varying(255), + "lastError" text, + "processedAt" timestamp with time zone, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "message_outbox_events_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "message_reactions" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "messageId" uuid NOT NULL, + "channelId" uuid NOT NULL, + "conversationId" uuid, + "userId" uuid NOT NULL, + "emoji" character varying(50) NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "message_reactions_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "messages" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "channelId" uuid NOT NULL, + "conversationId" uuid, + "authorId" uuid NOT NULL, + "content" text NOT NULL, + "embeds" jsonb, + "replyToMessageId" uuid, + "threadChannelId" uuid, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone, + "deletedAt" timestamp with time zone, + CONSTRAINT "messages_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "notifications" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "memberId" uuid NOT NULL, + "targetedResourceId" uuid, + "targetedResourceType" character varying(50), + "resourceId" uuid, + "resourceType" character varying(50), + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "readAt" timestamp with time zone, + CONSTRAINT "notifications_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "organization_members" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "organizationId" uuid NOT NULL, + "userId" uuid NOT NULL, + "role" public.organization_role NOT NULL DEFAULT 'member', + "nickname" character varying(100), + "joinedAt" timestamp with time zone NOT NULL DEFAULT now(), + "invitedBy" uuid, + "metadata" jsonb, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "organization_members_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "organizations" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "name" character varying(255) NOT NULL, + "slug" character varying(100), + "logoUrl" text, + "settings" jsonb, + "isPublic" boolean NOT NULL DEFAULT false, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "organizations_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "pinned_messages" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "channelId" uuid NOT NULL, + "messageId" uuid NOT NULL, + "pinnedBy" uuid NOT NULL, + "pinnedAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "pinned_messages_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "rss_posted_items" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "subscriptionId" uuid NOT NULL, + "itemGuid" text NOT NULL, + "itemUrl" text, + "messageId" uuid, + "postedAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "rss_posted_items_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "rss_subscriptions" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "channelId" uuid NOT NULL, + "organizationId" uuid NOT NULL, + "feedUrl" text NOT NULL, + "feedTitle" character varying(255), + "feedIconUrl" text, + "lastFetchedAt" timestamp with time zone, + "lastItemPublishedAt" timestamp with time zone, + "lastItemGuid" text, + "consecutiveErrors" integer NOT NULL DEFAULT 0, + "lastErrorMessage" text, + "lastErrorAt" timestamp with time zone, + "isEnabled" boolean NOT NULL DEFAULT true, + "pollingIntervalMinutes" integer NOT NULL DEFAULT 15, + "createdBy" uuid NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "rss_subscriptions_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "typing_indicators" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "channelId" uuid NOT NULL, + "memberId" uuid NOT NULL, + "lastTyped" bigint NOT NULL, + CONSTRAINT "typing_indicators_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "user_presence_status" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "status" public.user_presence_status_enum NOT NULL DEFAULT 'online', + "customMessage" character varying(255), + "statusEmoji" character varying(10), + "statusExpiresAt" timestamp with time zone, + "activeChannelId" uuid, + "suppressNotifications" boolean NOT NULL DEFAULT false, + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "lastSeenAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "user_presence_status_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "users" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "externalId" character varying(255) NOT NULL, + "email" character varying(255) NOT NULL, + "firstName" character varying(100) NOT NULL, + "lastName" character varying(100) NOT NULL, + "avatarUrl" text, + "userType" public.user_type NOT NULL DEFAULT 'user', + "settings" jsonb, + "isOnboarded" boolean NOT NULL DEFAULT false, + "timezone" character varying(100), + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "deletedAt" timestamp with time zone, + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "attachments_channel_id_idx" ON "attachments" USING btree ("channelId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "attachments_deleted_at_idx" ON "attachments" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "attachments_message_id_idx" ON "attachments" USING btree ("messageId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "attachments_message_uploaded_at_idx" ON "attachments" USING btree ("messageId", "uploadedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "attachments_organization_id_idx" ON "attachments" USING btree ("organizationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "attachments_status_idx" ON "attachments" USING btree ("status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "attachments_uploaded_by_idx" ON "attachments" USING btree ("uploadedBy"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "bot_commands_bot_id_idx" ON "bot_commands" USING btree ("botId"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "bot_commands_bot_id_name_idx" ON "bot_commands" USING btree ("botId", "name"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "bot_installations_bot_idx" ON "bot_installations" USING btree ("botId"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "bot_installations_bot_org_unique" ON "bot_installations" USING btree ("botId", "organizationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "bot_installations_org_idx" ON "bot_installations" USING btree ("organizationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "bots_created_by_idx" ON "bots" USING btree ("createdBy"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "bots_deleted_at_idx" ON "bots" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "bots_is_public_idx" ON "bots" USING btree ("isPublic"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "bots_userId_unique" ON "bots" USING btree ("userId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "bots_user_id_idx" ON "bots" USING btree ("userId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channel_access_channel_id_idx" ON "channel_access" USING btree ("channelId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channel_access_org_user_idx" ON "channel_access" USING btree ("organizationId", "userId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channel_access_organization_id_idx" ON "channel_access" USING btree ("organizationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channel_access_user_id_idx" ON "channel_access" USING btree ("userId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channel_members_channel_id_idx" ON "channel_members" USING btree ("channelId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channel_members_channel_user_idx" ON "channel_members" USING btree ("channelId", "userId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channel_members_user_id_idx" ON "channel_members" USING btree ("userId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channel_sections_deleted_at_idx" ON "channel_sections" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channel_sections_order_idx" ON "channel_sections" USING btree ("organizationId", "order"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channel_sections_organization_id_idx" ON "channel_sections" USING btree ("organizationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channel_webhooks_bot_user_id_idx" ON "channel_webhooks" USING btree ("botUserId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channel_webhooks_channel_id_idx" ON "channel_webhooks" USING btree ("channelId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channel_webhooks_deleted_at_idx" ON "channel_webhooks" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channel_webhooks_organization_id_idx" ON "channel_webhooks" USING btree ("organizationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channel_webhooks_token_hash_idx" ON "channel_webhooks" USING btree ("tokenHash"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channels_deleted_at_idx" ON "channels" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channels_organization_id_idx" ON "channels" USING btree ("organizationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channels_parent_channel_id_idx" ON "channels" USING btree ("parentChannelId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channels_section_id_idx" ON "channels" USING btree ("sectionId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "channels_type_idx" ON "channels" USING btree ("type"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "chat_sync_link_connection_external_unique" ON "chat_sync_channel_links" USING btree ("syncConnectionId", "externalChannelId") WHERE "deletedAt" IS NULL; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "chat_sync_link_connection_hazel_unique" ON "chat_sync_channel_links" USING btree ("syncConnectionId", "hazelChannelId") WHERE "deletedAt" IS NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "chat_sync_link_connection_idx" ON "chat_sync_channel_links" USING btree ("syncConnectionId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "chat_sync_link_deleted_at_idx" ON "chat_sync_channel_links" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "chat_sync_link_external_channel_idx" ON "chat_sync_channel_links" USING btree ("externalChannelId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "chat_sync_link_hazel_channel_idx" ON "chat_sync_channel_links" USING btree ("hazelChannelId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "chat_sync_conn_deleted_at_idx" ON "chat_sync_connections" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "chat_sync_conn_integration_idx" ON "chat_sync_connections" USING btree ("integrationConnectionId"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "chat_sync_conn_integration_unique" ON "chat_sync_connections" USING btree ("integrationConnectionId") WHERE "deletedAt" IS NULL AND "integrationConnectionId" IS NOT NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "chat_sync_conn_org_idx" ON "chat_sync_connections" USING btree ("organizationId"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "chat_sync_conn_org_provider_workspace_unique" ON "chat_sync_connections" USING btree ("organizationId", "provider", "externalWorkspaceId") WHERE "deletedAt" IS NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "chat_sync_conn_provider_idx" ON "chat_sync_connections" USING btree ("provider"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "chat_sync_conn_status_idx" ON "chat_sync_connections" USING btree ("status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "chat_sync_receipt_channel_link_idx" ON "chat_sync_event_receipts" USING btree ("channelLinkId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "chat_sync_receipt_connection_idx" ON "chat_sync_event_receipts" USING btree ("syncConnectionId"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "chat_sync_receipt_dedupe_unique" ON "chat_sync_event_receipts" USING btree ("syncConnectionId", "source", "dedupeKey"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "chat_sync_receipt_processed_at_idx" ON "chat_sync_event_receipts" USING btree ("processedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "chat_sync_receipt_source_idx" ON "chat_sync_event_receipts" USING btree ("source"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "chat_sync_msg_link_channel_idx" ON "chat_sync_message_links" USING btree ("channelLinkId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "chat_sync_msg_link_deleted_at_idx" ON "chat_sync_message_links" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "chat_sync_msg_link_external_message_idx" ON "chat_sync_message_links" USING btree ("externalMessageId"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "chat_sync_msg_link_external_unique" ON "chat_sync_message_links" USING btree ("channelLinkId", "externalMessageId") WHERE "deletedAt" IS NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "chat_sync_msg_link_hazel_message_idx" ON "chat_sync_message_links" USING btree ("hazelMessageId"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "chat_sync_msg_link_hazel_unique" ON "chat_sync_message_links" USING btree ("channelLinkId", "hazelMessageId") WHERE "deletedAt" IS NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "chat_sync_msg_link_root_hazel_idx" ON "chat_sync_message_links" USING btree ("rootHazelMessageId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connect_conv_channels_channel_idx" ON "connect_conversation_channels" USING btree ("channelId"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "connect_conv_channels_channel_unique" ON "connect_conversation_channels" USING btree ("channelId") WHERE "deletedAt" IS NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connect_conv_channels_conversation_idx" ON "connect_conversation_channels" USING btree ("conversationId"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "connect_conv_channels_conversation_org_unique" ON "connect_conversation_channels" USING btree ("conversationId", "organizationId") WHERE "deletedAt" IS NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connect_conv_channels_deleted_at_idx" ON "connect_conversation_channels" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connect_conv_channels_org_idx" ON "connect_conversation_channels" USING btree ("organizationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connect_conversations_deleted_at_idx" ON "connect_conversations" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connect_conversations_host_channel_idx" ON "connect_conversations" USING btree ("hostChannelId"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "connect_conversations_host_channel_unique" ON "connect_conversations" USING btree ("hostChannelId") WHERE "deletedAt" IS NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connect_conversations_host_org_idx" ON "connect_conversations" USING btree ("hostOrganizationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connect_conversations_status_idx" ON "connect_conversations" USING btree ("status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connect_invites_conversation_idx" ON "connect_invites" USING btree ("conversationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connect_invites_deleted_at_idx" ON "connect_invites" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connect_invites_guest_org_idx" ON "connect_invites" USING btree ("guestOrganizationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connect_invites_host_org_idx" ON "connect_invites" USING btree ("hostOrganizationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connect_invites_status_idx" ON "connect_invites" USING btree ("status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connect_invites_target_idx" ON "connect_invites" USING btree ("targetKind", "targetValue"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connect_participants_channel_idx" ON "connect_participants" USING btree ("channelId"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "connect_participants_channel_user_unique" ON "connect_participants" USING btree ("channelId", "userId") WHERE "deletedAt" IS NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connect_participants_conversation_idx" ON "connect_participants" USING btree ("conversationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connect_participants_deleted_at_idx" ON "connect_participants" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connect_participants_user_idx" ON "connect_participants" USING btree ("userId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "custom_emojis_deleted_at_idx" ON "custom_emojis" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "custom_emojis_name_idx" ON "custom_emojis" USING btree ("name"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "custom_emojis_org_name_unique_idx" ON "custom_emojis" USING btree ("organizationId", "name") WHERE "deletedAt" IS NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "custom_emojis_organization_id_idx" ON "custom_emojis" USING btree ("organizationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "github_subscriptions_channel_id_idx" ON "github_subscriptions" USING btree ("channelId"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "github_subscriptions_channel_repo_unique" ON "github_subscriptions" USING btree ("channelId", "repositoryId") WHERE "deletedAt" IS NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "github_subscriptions_deleted_at_idx" ON "github_subscriptions" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "github_subscriptions_organization_id_idx" ON "github_subscriptions" USING btree ("organizationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "github_subscriptions_repository_id_idx" ON "github_subscriptions" USING btree ("repositoryId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "int_conn_deleted_at_idx" ON "integration_connections" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "int_conn_github_installation_idx" ON "integration_connections" USING btree (("metadata" ->> 'installationId')); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "int_conn_org_idx" ON "integration_connections" USING btree ("organizationId"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "int_conn_org_provider_external_user_unique" ON "integration_connections" USING btree ("organizationId", "provider", "externalAccountId") WHERE "deletedAt" IS NULL + AND "level" = 'user' + AND "externalAccountId" IS NOT NULL; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "int_conn_org_user_provider_unique" ON "integration_connections" USING btree ("organizationId", "userId", "provider"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "int_conn_provider_idx" ON "integration_connections" USING btree ("provider"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "int_conn_status_idx" ON "integration_connections" USING btree ("status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "integration_requests_created_at_idx" ON "integration_requests" USING btree ("createdAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "integration_requests_organization_id_idx" ON "integration_requests" USING btree ("organizationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "integration_requests_status_idx" ON "integration_requests" USING btree ("status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "int_tokens_connection_id_idx" ON "integration_tokens" USING btree ("connectionId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "int_tokens_expires_at_idx" ON "integration_tokens" USING btree ("expiresAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "int_tokens_key_version_idx" ON "integration_tokens" USING btree ("encryptionKeyVersion"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "integration_tokens_connectionId_unique" ON "integration_tokens" USING btree ("connectionId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "message_outbox_events_channel_sequence_idx" ON "message_outbox_events" USING btree ("channelId", "sequence"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "message_outbox_events_created_at_idx" ON "message_outbox_events" USING btree ("createdAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "message_outbox_events_locked_at_idx" ON "message_outbox_events" USING btree ("lockedAt") WHERE "status" = 'processing'; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "message_outbox_events_sequence_unique" ON "message_outbox_events" USING btree ("sequence"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "message_outbox_events_status_available_sequence_idx" ON "message_outbox_events" USING btree ("status", "availableAt", "sequence"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "reactions_channel_id_idx" ON "message_reactions" USING btree ("channelId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "reactions_conversation_id_idx" ON "message_reactions" USING btree ("conversationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "reactions_message_id_idx" ON "message_reactions" USING btree ("messageId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "reactions_message_user_emoji_idx" ON "message_reactions" USING btree ("messageId", "userId", "emoji"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "reactions_message_user_emoji_unique" ON "message_reactions" USING btree ("messageId", "userId", "emoji"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "reactions_user_id_idx" ON "message_reactions" USING btree ("userId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "messages_author_id_idx" ON "messages" USING btree ("authorId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "messages_channel_id_idx" ON "messages" USING btree ("channelId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "messages_conversation_id_idx" ON "messages" USING btree ("conversationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "messages_created_at_idx" ON "messages" USING btree ("createdAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "messages_deleted_at_idx" ON "messages" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "messages_reply_to_idx" ON "messages" USING btree ("replyToMessageId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "messages_thread_channel_idx" ON "messages" USING btree ("threadChannelId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "notifications_member_created_id_idx" ON "notifications" USING btree ("memberId", "createdAt", "id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "notifications_member_id_idx" ON "notifications" USING btree ("memberId"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "notifications_message_channel_dedupe_idx" ON "notifications" USING btree ("memberId", "resourceId", "targetedResourceId") WHERE "resourceType" = 'message' AND "targetedResourceType" = 'channel'; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "notifications_read_at_idx" ON "notifications" USING btree ("readAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "notifications_resource_idx" ON "notifications" USING btree ("resourceId", "resourceType"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "notifications_targeted_resource_idx" ON "notifications" USING btree ("targetedResourceId", "targetedResourceType"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "org_members_org_user_unique" ON "organization_members" USING btree ("organizationId", "userId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "org_members_organization_id_idx" ON "organization_members" USING btree ("organizationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "org_members_user_id_idx" ON "organization_members" USING btree ("userId"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "organizations_clerk_org_id_unique" ON "organizations" USING btree (("settings" ->> 'clerkOrganizationId')) WHERE "deletedAt" IS NULL AND "settings"->>'clerkOrganizationId' IS NOT NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "organizations_deleted_at_idx" ON "organizations" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "organizations_slug_idx" ON "organizations" USING btree ("slug"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "organizations_slug_unique" ON "organizations" USING btree ("slug"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "pinned_messages_channel_id_idx" ON "pinned_messages" USING btree ("channelId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "pinned_messages_message_id_idx" ON "pinned_messages" USING btree ("messageId"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "pinned_messages_unique_idx" ON "pinned_messages" USING btree ("channelId", "messageId"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "rss_posted_items_sub_guid_unique" ON "rss_posted_items" USING btree ("subscriptionId", "itemGuid"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "rss_posted_items_subscription_id_idx" ON "rss_posted_items" USING btree ("subscriptionId"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "rss_subscriptions_channel_feed_unique" ON "rss_subscriptions" USING btree ("channelId", "feedUrl") WHERE "deletedAt" IS NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "rss_subscriptions_channel_id_idx" ON "rss_subscriptions" USING btree ("channelId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "rss_subscriptions_deleted_at_idx" ON "rss_subscriptions" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "rss_subscriptions_organization_id_idx" ON "rss_subscriptions" USING btree ("organizationId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "rss_subscriptions_poll_idx" ON "rss_subscriptions" USING btree ("isEnabled", "lastFetchedAt", "deletedAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "typing_indicators_channel_member_unique" ON "typing_indicators" USING btree ("channelId", "memberId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "typing_indicators_channel_timestamp_idx" ON "typing_indicators" USING btree ("channelId", "lastTyped"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "typing_indicators_timestamp_idx" ON "typing_indicators" USING btree ("lastTyped"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_presence_status_active_channel_idx" ON "user_presence_status" USING btree ("activeChannelId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_presence_status_last_seen_idx" ON "user_presence_status" USING btree ("lastSeenAt"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "user_presence_status_userId_unique" ON "user_presence_status" USING btree ("userId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_presence_status_user_id_idx" ON "user_presence_status" USING btree ("userId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "users_deleted_at_idx" ON "users" USING btree ("deletedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "users_email_idx" ON "users" USING btree ("email"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "users_externalId_unique" ON "users" USING btree ("externalId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "users_external_id_idx" ON "users" USING btree ("externalId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "users_user_type_idx" ON "users" USING btree ("userType"); +--> statement-breakpoint +ALTER TABLE "organization_members" ADD CONSTRAINT "organization_members_organizationId_organizations_id_fk" FOREIGN KEY ("organizationId") REFERENCES "organizations" ("id") ON DELETE CASCADE; diff --git a/packages/db/migrations/20261012222436_effect_orm_baseline/migration.sql b/packages/db/migrations/20261012222436_effect_orm_baseline/migration.sql new file mode 100644 index 000000000..7a2967e06 --- /dev/null +++ b/packages/db/migrations/20261012222436_effect_orm_baseline/migration.sql @@ -0,0 +1 @@ +-- Baseline: the schema as it stood when effect-orm took over this folder. Runs nothing. diff --git a/packages/db/migrations/20261012222436_effect_orm_baseline/snapshot.json b/packages/db/migrations/20261012222436_effect_orm_baseline/snapshot.json new file mode 100644 index 000000000..8987a60e5 --- /dev/null +++ b/packages/db/migrations/20261012222436_effect_orm_baseline/snapshot.json @@ -0,0 +1,5340 @@ +{ + "version": "1", + "dialect": "postgres", + "id": "9630633859de45b3a47113409704ffc3527838ed939973fc94d6e3fec6499f78", + "prevIds": ["0000000000000000000000000000000000000000000000000000000000000000"], + "entities": [ + { + "kind": "table", + "name": "attachments", + "primaryKey": { + "name": "attachments_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "bot_commands", + "primaryKey": { + "name": "bot_commands_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "bot_installations", + "primaryKey": { + "name": "bot_installations_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "bots", + "primaryKey": { + "name": "bots_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "channel_access", + "primaryKey": { + "name": "channel_access_user_channel_pk", + "columns": ["userId", "channelId"] + } + }, + { + "kind": "table", + "name": "channel_members", + "primaryKey": { + "name": "channel_members_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "channel_sections", + "primaryKey": { + "name": "channel_sections_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "channel_webhooks", + "primaryKey": { + "name": "channel_webhooks_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "channels", + "primaryKey": { + "name": "channels_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "chat_sync_channel_links", + "primaryKey": { + "name": "chat_sync_channel_links_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "chat_sync_connections", + "primaryKey": { + "name": "chat_sync_connections_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "chat_sync_event_receipts", + "primaryKey": { + "name": "chat_sync_event_receipts_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "chat_sync_message_links", + "primaryKey": { + "name": "chat_sync_message_links_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "connect_conversation_channels", + "primaryKey": { + "name": "connect_conversation_channels_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "connect_conversations", + "primaryKey": { + "name": "connect_conversations_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "connect_invites", + "primaryKey": { + "name": "connect_invites_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "connect_participants", + "primaryKey": { + "name": "connect_participants_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "custom_emojis", + "primaryKey": { + "name": "custom_emojis_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "github_subscriptions", + "primaryKey": { + "name": "github_subscriptions_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "integration_connections", + "primaryKey": { + "name": "integration_connections_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "integration_requests", + "primaryKey": { + "name": "integration_requests_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "integration_tokens", + "primaryKey": { + "name": "integration_tokens_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "message_outbox_events", + "primaryKey": { + "name": "message_outbox_events_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "message_reactions", + "primaryKey": { + "name": "message_reactions_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "messages", + "primaryKey": { + "name": "messages_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "notifications", + "primaryKey": { + "name": "notifications_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "organization_members", + "primaryKey": { + "name": "organization_members_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "organizations", + "primaryKey": { + "name": "organizations_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "pinned_messages", + "primaryKey": { + "name": "pinned_messages_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "rss_posted_items", + "primaryKey": { + "name": "rss_posted_items_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "rss_subscriptions", + "primaryKey": { + "name": "rss_subscriptions_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "typing_indicators", + "primaryKey": { + "name": "typing_indicators_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "user_presence_status", + "primaryKey": { + "name": "user_presence_status_pkey", + "columns": ["id"] + } + }, + { + "kind": "table", + "name": "users", + "primaryKey": { + "name": "users_pkey", + "columns": ["id"] + } + }, + { + "kind": "column", + "table": "attachments", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "attachments", + "name": "organizationId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "attachments", + "name": "channelId", + "position": 2, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "attachments", + "name": "messageId", + "position": 3, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "attachments", + "name": "fileName", + "position": 4, + "type": "character varying(255)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "attachments", + "name": "fileSize", + "position": 5, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "attachments", + "name": "externalUrl", + "position": 6, + "type": "character varying(2048)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "attachments", + "name": "uploadedBy", + "position": 7, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "attachments", + "name": "status", + "position": 8, + "type": "public.attachment_status", + "notNull": true, + "default": "'uploading'", + "identity": null + }, + { + "kind": "column", + "table": "attachments", + "name": "uploadedAt", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "attachments", + "name": "deletedAt", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "bot_commands", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "bot_commands", + "name": "botId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "bot_commands", + "name": "name", + "position": 2, + "type": "character varying(50)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "bot_commands", + "name": "description", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "bot_commands", + "name": "arguments", + "position": 4, + "type": "jsonb", + "notNull": false, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "bot_commands", + "name": "usageExample", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "bot_commands", + "name": "isEnabled", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "bot_commands", + "name": "createdAt", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "bot_commands", + "name": "updatedAt", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "bot_installations", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "bot_installations", + "name": "botId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "bot_installations", + "name": "organizationId", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "bot_installations", + "name": "installedBy", + "position": 3, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "bot_installations", + "name": "installedAt", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "bots", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "bots", + "name": "userId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "bots", + "name": "createdBy", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "bots", + "name": "name", + "position": 3, + "type": "character varying(100)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "bots", + "name": "description", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "bots", + "name": "webhookUrl", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "bots", + "name": "apiTokenHash", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "bots", + "name": "scopes", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "bots", + "name": "metadata", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "bots", + "name": "isPublic", + "position": 9, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "bots", + "name": "installCount", + "position": 10, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "bots", + "name": "mentionable", + "position": 11, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "bots", + "name": "allowedIntegrations", + "position": 12, + "type": "jsonb", + "notNull": false, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "bots", + "name": "createdAt", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "bots", + "name": "updatedAt", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "bots", + "name": "deletedAt", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_access", + "name": "userId", + "position": 0, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_access", + "name": "channelId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_access", + "name": "organizationId", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_access", + "name": "createdAt", + "position": 3, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "channel_access", + "name": "updatedAt", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "channel_members", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "channel_members", + "name": "channelId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_members", + "name": "userId", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_members", + "name": "isHidden", + "position": 3, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "channel_members", + "name": "isMuted", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "channel_members", + "name": "isFavorite", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "channel_members", + "name": "lastSeenMessageId", + "position": 6, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_members", + "name": "notificationCount", + "position": 7, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "channel_members", + "name": "joinedAt", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "channel_members", + "name": "createdAt", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "channel_members", + "name": "deletedAt", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_sections", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "channel_sections", + "name": "organizationId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_sections", + "name": "name", + "position": 2, + "type": "character varying(255)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_sections", + "name": "order", + "position": 3, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "channel_sections", + "name": "createdAt", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "channel_sections", + "name": "updatedAt", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "channel_sections", + "name": "deletedAt", + "position": 6, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_webhooks", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "channel_webhooks", + "name": "channelId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_webhooks", + "name": "organizationId", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_webhooks", + "name": "botUserId", + "position": 3, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_webhooks", + "name": "name", + "position": 4, + "type": "character varying(100)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_webhooks", + "name": "description", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_webhooks", + "name": "avatarUrl", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_webhooks", + "name": "tokenHash", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_webhooks", + "name": "tokenSuffix", + "position": 8, + "type": "character varying(4)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_webhooks", + "name": "isEnabled", + "position": 9, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "channel_webhooks", + "name": "createdBy", + "position": 10, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_webhooks", + "name": "lastUsedAt", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channel_webhooks", + "name": "createdAt", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "channel_webhooks", + "name": "updatedAt", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "channel_webhooks", + "name": "deletedAt", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channels", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "channels", + "name": "name", + "position": 1, + "type": "character varying(255)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channels", + "name": "icon", + "position": 2, + "type": "character varying(32)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channels", + "name": "type", + "position": 3, + "type": "public.channel_type", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channels", + "name": "organizationId", + "position": 4, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channels", + "name": "parentChannelId", + "position": 5, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channels", + "name": "sectionId", + "position": 6, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "channels", + "name": "createdAt", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "channels", + "name": "updatedAt", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "channels", + "name": "deletedAt", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_channel_links", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_channel_links", + "name": "syncConnectionId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_channel_links", + "name": "hazelChannelId", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_channel_links", + "name": "externalChannelId", + "position": 3, + "type": "character varying(255)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_channel_links", + "name": "externalChannelName", + "position": 4, + "type": "character varying(255)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_channel_links", + "name": "direction", + "position": 5, + "type": "public.chat_sync_direction", + "notNull": true, + "default": "'both'", + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_channel_links", + "name": "isActive", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_channel_links", + "name": "settings", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_channel_links", + "name": "lastSyncedAt", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_channel_links", + "name": "createdAt", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_channel_links", + "name": "updatedAt", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_channel_links", + "name": "deletedAt", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_connections", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_connections", + "name": "organizationId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_connections", + "name": "integrationConnectionId", + "position": 2, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_connections", + "name": "provider", + "position": 3, + "type": "character varying(50)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_connections", + "name": "externalWorkspaceId", + "position": 4, + "type": "character varying(255)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_connections", + "name": "externalWorkspaceName", + "position": 5, + "type": "character varying(255)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_connections", + "name": "status", + "position": 6, + "type": "public.chat_sync_connection_status", + "notNull": true, + "default": "'active'", + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_connections", + "name": "settings", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_connections", + "name": "metadata", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_connections", + "name": "errorMessage", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_connections", + "name": "lastSyncedAt", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_connections", + "name": "createdBy", + "position": 11, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_connections", + "name": "createdAt", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_connections", + "name": "updatedAt", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_connections", + "name": "deletedAt", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_event_receipts", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_event_receipts", + "name": "syncConnectionId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_event_receipts", + "name": "channelLinkId", + "position": 2, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_event_receipts", + "name": "source", + "position": 3, + "type": "public.chat_sync_receipt_source", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_event_receipts", + "name": "externalEventId", + "position": 4, + "type": "character varying(255)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_event_receipts", + "name": "dedupeKey", + "position": 5, + "type": "character varying(255)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_event_receipts", + "name": "payloadHash", + "position": 6, + "type": "character varying(128)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_event_receipts", + "name": "status", + "position": 7, + "type": "public.chat_sync_receipt_status", + "notNull": true, + "default": "'processed'", + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_event_receipts", + "name": "errorMessage", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_event_receipts", + "name": "processedAt", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_event_receipts", + "name": "createdAt", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_message_links", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_message_links", + "name": "channelLinkId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_message_links", + "name": "hazelMessageId", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_message_links", + "name": "externalMessageId", + "position": 3, + "type": "character varying(255)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_message_links", + "name": "source", + "position": 4, + "type": "public.chat_sync_receipt_source", + "notNull": true, + "default": "'hazel'", + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_message_links", + "name": "rootHazelMessageId", + "position": 5, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_message_links", + "name": "rootExternalMessageId", + "position": 6, + "type": "character varying(255)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_message_links", + "name": "hazelThreadChannelId", + "position": 7, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_message_links", + "name": "externalThreadId", + "position": 8, + "type": "character varying(255)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_message_links", + "name": "lastSyncedAt", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_message_links", + "name": "createdAt", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_message_links", + "name": "updatedAt", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "chat_sync_message_links", + "name": "deletedAt", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_conversation_channels", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "connect_conversation_channels", + "name": "conversationId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_conversation_channels", + "name": "organizationId", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_conversation_channels", + "name": "channelId", + "position": 3, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_conversation_channels", + "name": "role", + "position": 4, + "type": "public.connect_conversation_channel_role", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_conversation_channels", + "name": "allowGuestMemberAdds", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "connect_conversation_channels", + "name": "isActive", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "connect_conversation_channels", + "name": "createdAt", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "connect_conversation_channels", + "name": "updatedAt", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "connect_conversation_channels", + "name": "deletedAt", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_conversations", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "connect_conversations", + "name": "hostOrganizationId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_conversations", + "name": "hostChannelId", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_conversations", + "name": "status", + "position": 3, + "type": "public.connect_conversation_status", + "notNull": true, + "default": "'active'", + "identity": null + }, + { + "kind": "column", + "table": "connect_conversations", + "name": "settings", + "position": 4, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_conversations", + "name": "createdBy", + "position": 5, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_conversations", + "name": "createdAt", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "connect_conversations", + "name": "updatedAt", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "connect_conversations", + "name": "deletedAt", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_invites", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "connect_invites", + "name": "conversationId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_invites", + "name": "hostOrganizationId", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_invites", + "name": "hostChannelId", + "position": 3, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_invites", + "name": "targetKind", + "position": 4, + "type": "public.connect_invite_target_kind", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_invites", + "name": "targetValue", + "position": 5, + "type": "character varying(255)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_invites", + "name": "guestOrganizationId", + "position": 6, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_invites", + "name": "status", + "position": 7, + "type": "public.connect_invite_status", + "notNull": true, + "default": "'pending'", + "identity": null + }, + { + "kind": "column", + "table": "connect_invites", + "name": "allowGuestMemberAdds", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "connect_invites", + "name": "invitedBy", + "position": 9, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_invites", + "name": "acceptedBy", + "position": 10, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_invites", + "name": "acceptedAt", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_invites", + "name": "expiresAt", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_invites", + "name": "createdAt", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "connect_invites", + "name": "updatedAt", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "connect_invites", + "name": "deletedAt", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_participants", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "connect_participants", + "name": "conversationId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_participants", + "name": "channelId", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_participants", + "name": "userId", + "position": 3, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_participants", + "name": "homeOrganizationId", + "position": 4, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_participants", + "name": "isExternal", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "connect_participants", + "name": "addedBy", + "position": 6, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "connect_participants", + "name": "createdAt", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "connect_participants", + "name": "updatedAt", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "connect_participants", + "name": "deletedAt", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "custom_emojis", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "custom_emojis", + "name": "organizationId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "custom_emojis", + "name": "name", + "position": 2, + "type": "character varying(64)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "custom_emojis", + "name": "imageUrl", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "custom_emojis", + "name": "createdBy", + "position": 4, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "custom_emojis", + "name": "createdAt", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "custom_emojis", + "name": "updatedAt", + "position": 6, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "custom_emojis", + "name": "deletedAt", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "github_subscriptions", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "github_subscriptions", + "name": "channelId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "github_subscriptions", + "name": "organizationId", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "github_subscriptions", + "name": "repositoryId", + "position": 3, + "type": "bigint", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "github_subscriptions", + "name": "repositoryFullName", + "position": 4, + "type": "character varying(255)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "github_subscriptions", + "name": "repositoryOwner", + "position": 5, + "type": "character varying(255)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "github_subscriptions", + "name": "repositoryName", + "position": 6, + "type": "character varying(255)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "github_subscriptions", + "name": "enabledEvents", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'[\"push\",\"pull_request\",\"issues\"]'", + "identity": null + }, + { + "kind": "column", + "table": "github_subscriptions", + "name": "branchFilter", + "position": 8, + "type": "character varying(255)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "github_subscriptions", + "name": "isEnabled", + "position": 9, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "github_subscriptions", + "name": "createdBy", + "position": 10, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "github_subscriptions", + "name": "createdAt", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "github_subscriptions", + "name": "updatedAt", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "github_subscriptions", + "name": "deletedAt", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_connections", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "integration_connections", + "name": "provider", + "position": 1, + "type": "character varying(50)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_connections", + "name": "organizationId", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_connections", + "name": "userId", + "position": 3, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_connections", + "name": "level", + "position": 4, + "type": "public.connection_level", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_connections", + "name": "status", + "position": 5, + "type": "public.connection_status", + "notNull": true, + "default": "'active'", + "identity": null + }, + { + "kind": "column", + "table": "integration_connections", + "name": "externalAccountId", + "position": 6, + "type": "character varying(255)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_connections", + "name": "externalAccountName", + "position": 7, + "type": "character varying(255)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_connections", + "name": "connectedBy", + "position": 8, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_connections", + "name": "settings", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_connections", + "name": "metadata", + "position": 10, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_connections", + "name": "errorMessage", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_connections", + "name": "lastUsedAt", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_connections", + "name": "createdAt", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "integration_connections", + "name": "updatedAt", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "integration_connections", + "name": "deletedAt", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_requests", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "integration_requests", + "name": "organizationId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_requests", + "name": "requestedBy", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_requests", + "name": "integrationName", + "position": 3, + "type": "character varying(255)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_requests", + "name": "integrationUrl", + "position": 4, + "type": "character varying(500)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_requests", + "name": "description", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_requests", + "name": "status", + "position": 6, + "type": "public.integration_request_status", + "notNull": true, + "default": "'pending'", + "identity": null + }, + { + "kind": "column", + "table": "integration_requests", + "name": "createdAt", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "integration_requests", + "name": "updatedAt", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "integration_tokens", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "integration_tokens", + "name": "connectionId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_tokens", + "name": "encryptedAccessToken", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_tokens", + "name": "encryptedRefreshToken", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_tokens", + "name": "iv", + "position": 4, + "type": "character varying(32)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_tokens", + "name": "refreshTokenIv", + "position": 5, + "type": "character varying(32)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_tokens", + "name": "encryptionKeyVersion", + "position": 6, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "integration_tokens", + "name": "tokenType", + "position": 7, + "type": "character varying(50)", + "notNull": false, + "default": "'Bearer'", + "identity": null + }, + { + "kind": "column", + "table": "integration_tokens", + "name": "scope", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_tokens", + "name": "expiresAt", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_tokens", + "name": "refreshTokenExpiresAt", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_tokens", + "name": "lastRefreshedAt", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "integration_tokens", + "name": "createdAt", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "integration_tokens", + "name": "updatedAt", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "message_outbox_events", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "message_outbox_events", + "name": "sequence", + "position": 1, + "type": "bigint", + "notNull": true, + "default": null, + "identity": "always" + }, + { + "kind": "column", + "table": "message_outbox_events", + "name": "eventType", + "position": 2, + "type": "public.message_outbox_event_type", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "message_outbox_events", + "name": "aggregateId", + "position": 3, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "message_outbox_events", + "name": "channelId", + "position": 4, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "message_outbox_events", + "name": "payload", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "message_outbox_events", + "name": "status", + "position": 6, + "type": "public.message_outbox_event_status", + "notNull": true, + "default": "'pending'", + "identity": null + }, + { + "kind": "column", + "table": "message_outbox_events", + "name": "attemptCount", + "position": 7, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "message_outbox_events", + "name": "availableAt", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "message_outbox_events", + "name": "lockedAt", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "message_outbox_events", + "name": "lockedBy", + "position": 10, + "type": "character varying(255)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "message_outbox_events", + "name": "lastError", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "message_outbox_events", + "name": "processedAt", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "message_outbox_events", + "name": "createdAt", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "message_reactions", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "message_reactions", + "name": "messageId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "message_reactions", + "name": "channelId", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "message_reactions", + "name": "conversationId", + "position": 3, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "message_reactions", + "name": "userId", + "position": 4, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "message_reactions", + "name": "emoji", + "position": 5, + "type": "character varying(50)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "message_reactions", + "name": "createdAt", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "messages", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "messages", + "name": "channelId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "messages", + "name": "conversationId", + "position": 2, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "messages", + "name": "authorId", + "position": 3, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "messages", + "name": "content", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "messages", + "name": "embeds", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "messages", + "name": "replyToMessageId", + "position": 6, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "messages", + "name": "threadChannelId", + "position": 7, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "messages", + "name": "createdAt", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "messages", + "name": "updatedAt", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "messages", + "name": "deletedAt", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "notifications", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "notifications", + "name": "memberId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "notifications", + "name": "targetedResourceId", + "position": 2, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "notifications", + "name": "targetedResourceType", + "position": 3, + "type": "character varying(50)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "notifications", + "name": "resourceId", + "position": 4, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "notifications", + "name": "resourceType", + "position": 5, + "type": "character varying(50)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "notifications", + "name": "createdAt", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "notifications", + "name": "readAt", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "organization_members", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "organization_members", + "name": "organizationId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "organization_members", + "name": "userId", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "organization_members", + "name": "role", + "position": 3, + "type": "public.organization_role", + "notNull": true, + "default": "'member'", + "identity": null + }, + { + "kind": "column", + "table": "organization_members", + "name": "nickname", + "position": 4, + "type": "character varying(100)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "organization_members", + "name": "joinedAt", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "organization_members", + "name": "invitedBy", + "position": 6, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "organization_members", + "name": "metadata", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "organization_members", + "name": "createdAt", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "organization_members", + "name": "deletedAt", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "organizations", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "organizations", + "name": "name", + "position": 1, + "type": "character varying(255)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "organizations", + "name": "slug", + "position": 2, + "type": "character varying(100)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "organizations", + "name": "logoUrl", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "organizations", + "name": "settings", + "position": 4, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "organizations", + "name": "isPublic", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "organizations", + "name": "createdAt", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "organizations", + "name": "updatedAt", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "organizations", + "name": "deletedAt", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pinned_messages", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "pinned_messages", + "name": "channelId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pinned_messages", + "name": "messageId", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pinned_messages", + "name": "pinnedBy", + "position": 3, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pinned_messages", + "name": "pinnedAt", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "rss_posted_items", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "rss_posted_items", + "name": "subscriptionId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "rss_posted_items", + "name": "itemGuid", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "rss_posted_items", + "name": "itemUrl", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "rss_posted_items", + "name": "messageId", + "position": 4, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "rss_posted_items", + "name": "postedAt", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "rss_subscriptions", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "rss_subscriptions", + "name": "channelId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "rss_subscriptions", + "name": "organizationId", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "rss_subscriptions", + "name": "feedUrl", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "rss_subscriptions", + "name": "feedTitle", + "position": 4, + "type": "character varying(255)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "rss_subscriptions", + "name": "feedIconUrl", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "rss_subscriptions", + "name": "lastFetchedAt", + "position": 6, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "rss_subscriptions", + "name": "lastItemPublishedAt", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "rss_subscriptions", + "name": "lastItemGuid", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "rss_subscriptions", + "name": "consecutiveErrors", + "position": 9, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "rss_subscriptions", + "name": "lastErrorMessage", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "rss_subscriptions", + "name": "lastErrorAt", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "rss_subscriptions", + "name": "isEnabled", + "position": 12, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "rss_subscriptions", + "name": "pollingIntervalMinutes", + "position": 13, + "type": "integer", + "notNull": true, + "default": "15", + "identity": null + }, + { + "kind": "column", + "table": "rss_subscriptions", + "name": "createdBy", + "position": 14, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "rss_subscriptions", + "name": "createdAt", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "rss_subscriptions", + "name": "updatedAt", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "rss_subscriptions", + "name": "deletedAt", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "typing_indicators", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "typing_indicators", + "name": "channelId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "typing_indicators", + "name": "memberId", + "position": 2, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "typing_indicators", + "name": "lastTyped", + "position": 3, + "type": "bigint", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "user_presence_status", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "user_presence_status", + "name": "userId", + "position": 1, + "type": "uuid", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "user_presence_status", + "name": "status", + "position": 2, + "type": "public.user_presence_status_enum", + "notNull": true, + "default": "'online'", + "identity": null + }, + { + "kind": "column", + "table": "user_presence_status", + "name": "customMessage", + "position": 3, + "type": "character varying(255)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "user_presence_status", + "name": "statusEmoji", + "position": 4, + "type": "character varying(10)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "user_presence_status", + "name": "statusExpiresAt", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "user_presence_status", + "name": "activeChannelId", + "position": 6, + "type": "uuid", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "user_presence_status", + "name": "suppressNotifications", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "user_presence_status", + "name": "updatedAt", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "user_presence_status", + "name": "lastSeenAt", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "users", + "name": "id", + "position": 0, + "type": "uuid", + "notNull": true, + "default": "gen_random_uuid()", + "identity": null + }, + { + "kind": "column", + "table": "users", + "name": "externalId", + "position": 1, + "type": "character varying(255)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "users", + "name": "email", + "position": 2, + "type": "character varying(255)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "users", + "name": "firstName", + "position": 3, + "type": "character varying(100)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "users", + "name": "lastName", + "position": 4, + "type": "character varying(100)", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "users", + "name": "avatarUrl", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "users", + "name": "userType", + "position": 6, + "type": "public.user_type", + "notNull": true, + "default": "'user'", + "identity": null + }, + { + "kind": "column", + "table": "users", + "name": "settings", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "users", + "name": "isOnboarded", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "users", + "name": "timezone", + "position": 9, + "type": "character varying(100)", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "users", + "name": "createdAt", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "users", + "name": "updatedAt", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "users", + "name": "deletedAt", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "index", + "table": "attachments", + "name": "attachments_channel_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"channelId\""], + "where": null + }, + { + "kind": "index", + "table": "attachments", + "name": "attachments_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "attachments", + "name": "attachments_message_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"messageId\""], + "where": null + }, + { + "kind": "index", + "table": "attachments", + "name": "attachments_message_uploaded_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"messageId\"", "\"uploadedAt\""], + "where": null + }, + { + "kind": "index", + "table": "attachments", + "name": "attachments_organization_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"organizationId\""], + "where": null + }, + { + "kind": "index", + "table": "attachments", + "name": "attachments_status_idx", + "unique": false, + "method": "btree", + "columns": ["\"status\""], + "where": null + }, + { + "kind": "index", + "table": "attachments", + "name": "attachments_uploaded_by_idx", + "unique": false, + "method": "btree", + "columns": ["\"uploadedBy\""], + "where": null + }, + { + "kind": "index", + "table": "bot_commands", + "name": "bot_commands_bot_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"botId\""], + "where": null + }, + { + "kind": "index", + "table": "bot_commands", + "name": "bot_commands_bot_id_name_idx", + "unique": true, + "method": "btree", + "columns": ["\"botId\"", "\"name\""], + "where": null + }, + { + "kind": "index", + "table": "bot_installations", + "name": "bot_installations_bot_idx", + "unique": false, + "method": "btree", + "columns": ["\"botId\""], + "where": null + }, + { + "kind": "index", + "table": "bot_installations", + "name": "bot_installations_bot_org_unique", + "unique": true, + "method": "btree", + "columns": ["\"botId\"", "\"organizationId\""], + "where": null + }, + { + "kind": "index", + "table": "bot_installations", + "name": "bot_installations_org_idx", + "unique": false, + "method": "btree", + "columns": ["\"organizationId\""], + "where": null + }, + { + "kind": "index", + "table": "bots", + "name": "bots_created_by_idx", + "unique": false, + "method": "btree", + "columns": ["\"createdBy\""], + "where": null + }, + { + "kind": "index", + "table": "bots", + "name": "bots_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "bots", + "name": "bots_is_public_idx", + "unique": false, + "method": "btree", + "columns": ["\"isPublic\""], + "where": null + }, + { + "kind": "index", + "table": "bots", + "name": "bots_userId_unique", + "unique": true, + "method": "btree", + "columns": ["\"userId\""], + "where": null + }, + { + "kind": "index", + "table": "bots", + "name": "bots_user_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"userId\""], + "where": null + }, + { + "kind": "index", + "table": "channel_access", + "name": "channel_access_channel_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"channelId\""], + "where": null + }, + { + "kind": "index", + "table": "channel_access", + "name": "channel_access_org_user_idx", + "unique": false, + "method": "btree", + "columns": ["\"organizationId\"", "\"userId\""], + "where": null + }, + { + "kind": "index", + "table": "channel_access", + "name": "channel_access_organization_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"organizationId\""], + "where": null + }, + { + "kind": "index", + "table": "channel_access", + "name": "channel_access_user_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"userId\""], + "where": null + }, + { + "kind": "index", + "table": "channel_members", + "name": "channel_members_channel_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"channelId\""], + "where": null + }, + { + "kind": "index", + "table": "channel_members", + "name": "channel_members_channel_user_idx", + "unique": false, + "method": "btree", + "columns": ["\"channelId\"", "\"userId\""], + "where": null + }, + { + "kind": "index", + "table": "channel_members", + "name": "channel_members_user_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"userId\""], + "where": null + }, + { + "kind": "index", + "table": "channel_sections", + "name": "channel_sections_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "channel_sections", + "name": "channel_sections_order_idx", + "unique": false, + "method": "btree", + "columns": ["\"organizationId\"", "\"order\""], + "where": null + }, + { + "kind": "index", + "table": "channel_sections", + "name": "channel_sections_organization_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"organizationId\""], + "where": null + }, + { + "kind": "index", + "table": "channel_webhooks", + "name": "channel_webhooks_bot_user_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"botUserId\""], + "where": null + }, + { + "kind": "index", + "table": "channel_webhooks", + "name": "channel_webhooks_channel_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"channelId\""], + "where": null + }, + { + "kind": "index", + "table": "channel_webhooks", + "name": "channel_webhooks_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "channel_webhooks", + "name": "channel_webhooks_organization_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"organizationId\""], + "where": null + }, + { + "kind": "index", + "table": "channel_webhooks", + "name": "channel_webhooks_token_hash_idx", + "unique": false, + "method": "btree", + "columns": ["\"tokenHash\""], + "where": null + }, + { + "kind": "index", + "table": "channels", + "name": "channels_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "channels", + "name": "channels_organization_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"organizationId\""], + "where": null + }, + { + "kind": "index", + "table": "channels", + "name": "channels_parent_channel_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"parentChannelId\""], + "where": null + }, + { + "kind": "index", + "table": "channels", + "name": "channels_section_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"sectionId\""], + "where": null + }, + { + "kind": "index", + "table": "channels", + "name": "channels_type_idx", + "unique": false, + "method": "btree", + "columns": ["\"type\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_channel_links", + "name": "chat_sync_link_connection_external_unique", + "unique": true, + "method": "btree", + "columns": ["\"syncConnectionId\"", "\"externalChannelId\""], + "where": "\"deletedAt\" IS NULL" + }, + { + "kind": "index", + "table": "chat_sync_channel_links", + "name": "chat_sync_link_connection_hazel_unique", + "unique": true, + "method": "btree", + "columns": ["\"syncConnectionId\"", "\"hazelChannelId\""], + "where": "\"deletedAt\" IS NULL" + }, + { + "kind": "index", + "table": "chat_sync_channel_links", + "name": "chat_sync_link_connection_idx", + "unique": false, + "method": "btree", + "columns": ["\"syncConnectionId\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_channel_links", + "name": "chat_sync_link_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_channel_links", + "name": "chat_sync_link_external_channel_idx", + "unique": false, + "method": "btree", + "columns": ["\"externalChannelId\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_channel_links", + "name": "chat_sync_link_hazel_channel_idx", + "unique": false, + "method": "btree", + "columns": ["\"hazelChannelId\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_connections", + "name": "chat_sync_conn_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_connections", + "name": "chat_sync_conn_integration_idx", + "unique": false, + "method": "btree", + "columns": ["\"integrationConnectionId\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_connections", + "name": "chat_sync_conn_integration_unique", + "unique": true, + "method": "btree", + "columns": ["\"integrationConnectionId\""], + "where": "\"deletedAt\" IS NULL AND \"integrationConnectionId\" IS NOT NULL" + }, + { + "kind": "index", + "table": "chat_sync_connections", + "name": "chat_sync_conn_org_idx", + "unique": false, + "method": "btree", + "columns": ["\"organizationId\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_connections", + "name": "chat_sync_conn_org_provider_workspace_unique", + "unique": true, + "method": "btree", + "columns": ["\"organizationId\"", "\"provider\"", "\"externalWorkspaceId\""], + "where": "\"deletedAt\" IS NULL" + }, + { + "kind": "index", + "table": "chat_sync_connections", + "name": "chat_sync_conn_provider_idx", + "unique": false, + "method": "btree", + "columns": ["\"provider\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_connections", + "name": "chat_sync_conn_status_idx", + "unique": false, + "method": "btree", + "columns": ["\"status\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_event_receipts", + "name": "chat_sync_receipt_channel_link_idx", + "unique": false, + "method": "btree", + "columns": ["\"channelLinkId\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_event_receipts", + "name": "chat_sync_receipt_connection_idx", + "unique": false, + "method": "btree", + "columns": ["\"syncConnectionId\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_event_receipts", + "name": "chat_sync_receipt_dedupe_unique", + "unique": true, + "method": "btree", + "columns": ["\"syncConnectionId\"", "\"source\"", "\"dedupeKey\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_event_receipts", + "name": "chat_sync_receipt_processed_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"processedAt\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_event_receipts", + "name": "chat_sync_receipt_source_idx", + "unique": false, + "method": "btree", + "columns": ["\"source\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_message_links", + "name": "chat_sync_msg_link_channel_idx", + "unique": false, + "method": "btree", + "columns": ["\"channelLinkId\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_message_links", + "name": "chat_sync_msg_link_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_message_links", + "name": "chat_sync_msg_link_external_message_idx", + "unique": false, + "method": "btree", + "columns": ["\"externalMessageId\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_message_links", + "name": "chat_sync_msg_link_external_unique", + "unique": true, + "method": "btree", + "columns": ["\"channelLinkId\"", "\"externalMessageId\""], + "where": "\"deletedAt\" IS NULL" + }, + { + "kind": "index", + "table": "chat_sync_message_links", + "name": "chat_sync_msg_link_hazel_message_idx", + "unique": false, + "method": "btree", + "columns": ["\"hazelMessageId\""], + "where": null + }, + { + "kind": "index", + "table": "chat_sync_message_links", + "name": "chat_sync_msg_link_hazel_unique", + "unique": true, + "method": "btree", + "columns": ["\"channelLinkId\"", "\"hazelMessageId\""], + "where": "\"deletedAt\" IS NULL" + }, + { + "kind": "index", + "table": "chat_sync_message_links", + "name": "chat_sync_msg_link_root_hazel_idx", + "unique": false, + "method": "btree", + "columns": ["\"rootHazelMessageId\""], + "where": null + }, + { + "kind": "index", + "table": "connect_conversation_channels", + "name": "connect_conv_channels_channel_idx", + "unique": false, + "method": "btree", + "columns": ["\"channelId\""], + "where": null + }, + { + "kind": "index", + "table": "connect_conversation_channels", + "name": "connect_conv_channels_channel_unique", + "unique": true, + "method": "btree", + "columns": ["\"channelId\""], + "where": "\"deletedAt\" IS NULL" + }, + { + "kind": "index", + "table": "connect_conversation_channels", + "name": "connect_conv_channels_conversation_idx", + "unique": false, + "method": "btree", + "columns": ["\"conversationId\""], + "where": null + }, + { + "kind": "index", + "table": "connect_conversation_channels", + "name": "connect_conv_channels_conversation_org_unique", + "unique": true, + "method": "btree", + "columns": ["\"conversationId\"", "\"organizationId\""], + "where": "\"deletedAt\" IS NULL" + }, + { + "kind": "index", + "table": "connect_conversation_channels", + "name": "connect_conv_channels_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "connect_conversation_channels", + "name": "connect_conv_channels_org_idx", + "unique": false, + "method": "btree", + "columns": ["\"organizationId\""], + "where": null + }, + { + "kind": "index", + "table": "connect_conversations", + "name": "connect_conversations_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "connect_conversations", + "name": "connect_conversations_host_channel_idx", + "unique": false, + "method": "btree", + "columns": ["\"hostChannelId\""], + "where": null + }, + { + "kind": "index", + "table": "connect_conversations", + "name": "connect_conversations_host_channel_unique", + "unique": true, + "method": "btree", + "columns": ["\"hostChannelId\""], + "where": "\"deletedAt\" IS NULL" + }, + { + "kind": "index", + "table": "connect_conversations", + "name": "connect_conversations_host_org_idx", + "unique": false, + "method": "btree", + "columns": ["\"hostOrganizationId\""], + "where": null + }, + { + "kind": "index", + "table": "connect_conversations", + "name": "connect_conversations_status_idx", + "unique": false, + "method": "btree", + "columns": ["\"status\""], + "where": null + }, + { + "kind": "index", + "table": "connect_invites", + "name": "connect_invites_conversation_idx", + "unique": false, + "method": "btree", + "columns": ["\"conversationId\""], + "where": null + }, + { + "kind": "index", + "table": "connect_invites", + "name": "connect_invites_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "connect_invites", + "name": "connect_invites_guest_org_idx", + "unique": false, + "method": "btree", + "columns": ["\"guestOrganizationId\""], + "where": null + }, + { + "kind": "index", + "table": "connect_invites", + "name": "connect_invites_host_org_idx", + "unique": false, + "method": "btree", + "columns": ["\"hostOrganizationId\""], + "where": null + }, + { + "kind": "index", + "table": "connect_invites", + "name": "connect_invites_status_idx", + "unique": false, + "method": "btree", + "columns": ["\"status\""], + "where": null + }, + { + "kind": "index", + "table": "connect_invites", + "name": "connect_invites_target_idx", + "unique": false, + "method": "btree", + "columns": ["\"targetKind\"", "\"targetValue\""], + "where": null + }, + { + "kind": "index", + "table": "connect_participants", + "name": "connect_participants_channel_idx", + "unique": false, + "method": "btree", + "columns": ["\"channelId\""], + "where": null + }, + { + "kind": "index", + "table": "connect_participants", + "name": "connect_participants_channel_user_unique", + "unique": true, + "method": "btree", + "columns": ["\"channelId\"", "\"userId\""], + "where": "\"deletedAt\" IS NULL" + }, + { + "kind": "index", + "table": "connect_participants", + "name": "connect_participants_conversation_idx", + "unique": false, + "method": "btree", + "columns": ["\"conversationId\""], + "where": null + }, + { + "kind": "index", + "table": "connect_participants", + "name": "connect_participants_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "connect_participants", + "name": "connect_participants_user_idx", + "unique": false, + "method": "btree", + "columns": ["\"userId\""], + "where": null + }, + { + "kind": "index", + "table": "custom_emojis", + "name": "custom_emojis_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "custom_emojis", + "name": "custom_emojis_name_idx", + "unique": false, + "method": "btree", + "columns": ["\"name\""], + "where": null + }, + { + "kind": "index", + "table": "custom_emojis", + "name": "custom_emojis_org_name_unique_idx", + "unique": true, + "method": "btree", + "columns": ["\"organizationId\"", "\"name\""], + "where": "\"deletedAt\" IS NULL" + }, + { + "kind": "index", + "table": "custom_emojis", + "name": "custom_emojis_organization_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"organizationId\""], + "where": null + }, + { + "kind": "index", + "table": "github_subscriptions", + "name": "github_subscriptions_channel_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"channelId\""], + "where": null + }, + { + "kind": "index", + "table": "github_subscriptions", + "name": "github_subscriptions_channel_repo_unique", + "unique": true, + "method": "btree", + "columns": ["\"channelId\"", "\"repositoryId\""], + "where": "\"deletedAt\" IS NULL" + }, + { + "kind": "index", + "table": "github_subscriptions", + "name": "github_subscriptions_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "github_subscriptions", + "name": "github_subscriptions_organization_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"organizationId\""], + "where": null + }, + { + "kind": "index", + "table": "github_subscriptions", + "name": "github_subscriptions_repository_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"repositoryId\""], + "where": null + }, + { + "kind": "index", + "table": "integration_connections", + "name": "int_conn_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "integration_connections", + "name": "int_conn_github_installation_idx", + "unique": false, + "method": "btree", + "columns": ["(\"metadata\" ->> 'installationId')"], + "where": null + }, + { + "kind": "index", + "table": "integration_connections", + "name": "int_conn_org_idx", + "unique": false, + "method": "btree", + "columns": ["\"organizationId\""], + "where": null + }, + { + "kind": "index", + "table": "integration_connections", + "name": "int_conn_org_provider_external_user_unique", + "unique": true, + "method": "btree", + "columns": ["\"organizationId\"", "\"provider\"", "\"externalAccountId\""], + "where": "\"deletedAt\" IS NULL\n\t\t\t\t\tAND \"level\" = 'user'\n\t\t\t\t\tAND \"externalAccountId\" IS NOT NULL" + }, + { + "kind": "index", + "table": "integration_connections", + "name": "int_conn_org_user_provider_unique", + "unique": true, + "method": "btree", + "columns": ["\"organizationId\"", "\"userId\"", "\"provider\""], + "where": null + }, + { + "kind": "index", + "table": "integration_connections", + "name": "int_conn_provider_idx", + "unique": false, + "method": "btree", + "columns": ["\"provider\""], + "where": null + }, + { + "kind": "index", + "table": "integration_connections", + "name": "int_conn_status_idx", + "unique": false, + "method": "btree", + "columns": ["\"status\""], + "where": null + }, + { + "kind": "index", + "table": "integration_requests", + "name": "integration_requests_created_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"createdAt\""], + "where": null + }, + { + "kind": "index", + "table": "integration_requests", + "name": "integration_requests_organization_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"organizationId\""], + "where": null + }, + { + "kind": "index", + "table": "integration_requests", + "name": "integration_requests_status_idx", + "unique": false, + "method": "btree", + "columns": ["\"status\""], + "where": null + }, + { + "kind": "index", + "table": "integration_tokens", + "name": "int_tokens_connection_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"connectionId\""], + "where": null + }, + { + "kind": "index", + "table": "integration_tokens", + "name": "int_tokens_expires_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"expiresAt\""], + "where": null + }, + { + "kind": "index", + "table": "integration_tokens", + "name": "int_tokens_key_version_idx", + "unique": false, + "method": "btree", + "columns": ["\"encryptionKeyVersion\""], + "where": null + }, + { + "kind": "index", + "table": "integration_tokens", + "name": "integration_tokens_connectionId_unique", + "unique": true, + "method": "btree", + "columns": ["\"connectionId\""], + "where": null + }, + { + "kind": "index", + "table": "message_outbox_events", + "name": "message_outbox_events_channel_sequence_idx", + "unique": false, + "method": "btree", + "columns": ["\"channelId\"", "\"sequence\""], + "where": null + }, + { + "kind": "index", + "table": "message_outbox_events", + "name": "message_outbox_events_created_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"createdAt\""], + "where": null + }, + { + "kind": "index", + "table": "message_outbox_events", + "name": "message_outbox_events_locked_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"lockedAt\""], + "where": "\"status\" = 'processing'" + }, + { + "kind": "index", + "table": "message_outbox_events", + "name": "message_outbox_events_sequence_unique", + "unique": true, + "method": "btree", + "columns": ["\"sequence\""], + "where": null + }, + { + "kind": "index", + "table": "message_outbox_events", + "name": "message_outbox_events_status_available_sequence_idx", + "unique": false, + "method": "btree", + "columns": ["\"status\"", "\"availableAt\"", "\"sequence\""], + "where": null + }, + { + "kind": "index", + "table": "message_reactions", + "name": "reactions_channel_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"channelId\""], + "where": null + }, + { + "kind": "index", + "table": "message_reactions", + "name": "reactions_conversation_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"conversationId\""], + "where": null + }, + { + "kind": "index", + "table": "message_reactions", + "name": "reactions_message_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"messageId\""], + "where": null + }, + { + "kind": "index", + "table": "message_reactions", + "name": "reactions_message_user_emoji_idx", + "unique": false, + "method": "btree", + "columns": ["\"messageId\"", "\"userId\"", "\"emoji\""], + "where": null + }, + { + "kind": "index", + "table": "message_reactions", + "name": "reactions_message_user_emoji_unique", + "unique": true, + "method": "btree", + "columns": ["\"messageId\"", "\"userId\"", "\"emoji\""], + "where": null + }, + { + "kind": "index", + "table": "message_reactions", + "name": "reactions_user_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"userId\""], + "where": null + }, + { + "kind": "index", + "table": "messages", + "name": "messages_author_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"authorId\""], + "where": null + }, + { + "kind": "index", + "table": "messages", + "name": "messages_channel_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"channelId\""], + "where": null + }, + { + "kind": "index", + "table": "messages", + "name": "messages_conversation_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"conversationId\""], + "where": null + }, + { + "kind": "index", + "table": "messages", + "name": "messages_created_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"createdAt\""], + "where": null + }, + { + "kind": "index", + "table": "messages", + "name": "messages_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "messages", + "name": "messages_reply_to_idx", + "unique": false, + "method": "btree", + "columns": ["\"replyToMessageId\""], + "where": null + }, + { + "kind": "index", + "table": "messages", + "name": "messages_thread_channel_idx", + "unique": false, + "method": "btree", + "columns": ["\"threadChannelId\""], + "where": null + }, + { + "kind": "index", + "table": "notifications", + "name": "notifications_member_created_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"memberId\"", "\"createdAt\"", "\"id\""], + "where": null + }, + { + "kind": "index", + "table": "notifications", + "name": "notifications_member_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"memberId\""], + "where": null + }, + { + "kind": "index", + "table": "notifications", + "name": "notifications_message_channel_dedupe_idx", + "unique": true, + "method": "btree", + "columns": ["\"memberId\"", "\"resourceId\"", "\"targetedResourceId\""], + "where": "\"resourceType\" = 'message' AND \"targetedResourceType\" = 'channel'" + }, + { + "kind": "index", + "table": "notifications", + "name": "notifications_read_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"readAt\""], + "where": null + }, + { + "kind": "index", + "table": "notifications", + "name": "notifications_resource_idx", + "unique": false, + "method": "btree", + "columns": ["\"resourceId\"", "\"resourceType\""], + "where": null + }, + { + "kind": "index", + "table": "notifications", + "name": "notifications_targeted_resource_idx", + "unique": false, + "method": "btree", + "columns": ["\"targetedResourceId\"", "\"targetedResourceType\""], + "where": null + }, + { + "kind": "index", + "table": "organization_members", + "name": "org_members_org_user_unique", + "unique": true, + "method": "btree", + "columns": ["\"organizationId\"", "\"userId\""], + "where": null + }, + { + "kind": "index", + "table": "organization_members", + "name": "org_members_organization_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"organizationId\""], + "where": null + }, + { + "kind": "index", + "table": "organization_members", + "name": "org_members_user_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"userId\""], + "where": null + }, + { + "kind": "index", + "table": "organizations", + "name": "organizations_clerk_org_id_unique", + "unique": true, + "method": "btree", + "columns": ["(\"settings\" ->> 'clerkOrganizationId')"], + "where": "\"deletedAt\" IS NULL AND \"settings\"->>'clerkOrganizationId' IS NOT NULL" + }, + { + "kind": "index", + "table": "organizations", + "name": "organizations_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "organizations", + "name": "organizations_slug_idx", + "unique": false, + "method": "btree", + "columns": ["\"slug\""], + "where": null + }, + { + "kind": "index", + "table": "organizations", + "name": "organizations_slug_unique", + "unique": true, + "method": "btree", + "columns": ["\"slug\""], + "where": null + }, + { + "kind": "index", + "table": "pinned_messages", + "name": "pinned_messages_channel_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"channelId\""], + "where": null + }, + { + "kind": "index", + "table": "pinned_messages", + "name": "pinned_messages_message_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"messageId\""], + "where": null + }, + { + "kind": "index", + "table": "pinned_messages", + "name": "pinned_messages_unique_idx", + "unique": true, + "method": "btree", + "columns": ["\"channelId\"", "\"messageId\""], + "where": null + }, + { + "kind": "index", + "table": "rss_posted_items", + "name": "rss_posted_items_sub_guid_unique", + "unique": true, + "method": "btree", + "columns": ["\"subscriptionId\"", "\"itemGuid\""], + "where": null + }, + { + "kind": "index", + "table": "rss_posted_items", + "name": "rss_posted_items_subscription_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"subscriptionId\""], + "where": null + }, + { + "kind": "index", + "table": "rss_subscriptions", + "name": "rss_subscriptions_channel_feed_unique", + "unique": true, + "method": "btree", + "columns": ["\"channelId\"", "\"feedUrl\""], + "where": "\"deletedAt\" IS NULL" + }, + { + "kind": "index", + "table": "rss_subscriptions", + "name": "rss_subscriptions_channel_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"channelId\""], + "where": null + }, + { + "kind": "index", + "table": "rss_subscriptions", + "name": "rss_subscriptions_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "rss_subscriptions", + "name": "rss_subscriptions_organization_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"organizationId\""], + "where": null + }, + { + "kind": "index", + "table": "rss_subscriptions", + "name": "rss_subscriptions_poll_idx", + "unique": false, + "method": "btree", + "columns": ["\"isEnabled\"", "\"lastFetchedAt\"", "\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "typing_indicators", + "name": "typing_indicators_channel_member_unique", + "unique": true, + "method": "btree", + "columns": ["\"channelId\"", "\"memberId\""], + "where": null + }, + { + "kind": "index", + "table": "typing_indicators", + "name": "typing_indicators_channel_timestamp_idx", + "unique": false, + "method": "btree", + "columns": ["\"channelId\"", "\"lastTyped\""], + "where": null + }, + { + "kind": "index", + "table": "typing_indicators", + "name": "typing_indicators_timestamp_idx", + "unique": false, + "method": "btree", + "columns": ["\"lastTyped\""], + "where": null + }, + { + "kind": "index", + "table": "user_presence_status", + "name": "user_presence_status_active_channel_idx", + "unique": false, + "method": "btree", + "columns": ["\"activeChannelId\""], + "where": null + }, + { + "kind": "index", + "table": "user_presence_status", + "name": "user_presence_status_last_seen_idx", + "unique": false, + "method": "btree", + "columns": ["\"lastSeenAt\""], + "where": null + }, + { + "kind": "index", + "table": "user_presence_status", + "name": "user_presence_status_userId_unique", + "unique": true, + "method": "btree", + "columns": ["\"userId\""], + "where": null + }, + { + "kind": "index", + "table": "user_presence_status", + "name": "user_presence_status_user_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"userId\""], + "where": null + }, + { + "kind": "index", + "table": "users", + "name": "users_deleted_at_idx", + "unique": false, + "method": "btree", + "columns": ["\"deletedAt\""], + "where": null + }, + { + "kind": "index", + "table": "users", + "name": "users_email_idx", + "unique": false, + "method": "btree", + "columns": ["\"email\""], + "where": null + }, + { + "kind": "index", + "table": "users", + "name": "users_externalId_unique", + "unique": true, + "method": "btree", + "columns": ["\"externalId\""], + "where": null + }, + { + "kind": "index", + "table": "users", + "name": "users_external_id_idx", + "unique": false, + "method": "btree", + "columns": ["\"externalId\""], + "where": null + }, + { + "kind": "index", + "table": "users", + "name": "users_user_type_idx", + "unique": false, + "method": "btree", + "columns": ["\"userType\""], + "where": null + }, + { + "kind": "foreign_key", + "table": "organization_members", + "name": "organization_members_organizationId_organizations_id_fk", + "columns": ["organizationId"], + "foreignTable": "organizations", + "foreignColumns": ["id"], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + } + ] +} diff --git a/packages/db/package.json b/packages/db/package.json index 0795d0ddc..9f1326718 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -6,30 +6,29 @@ "main": "src/index.ts", "exports": { ".": "./src/index.ts", - "./schema": "./src/schema/index.ts", - "./tables": "./src/tables/index.ts" + "./schema": "./src/tables/index.ts", + "./tables": "./src/tables/index.ts", + "./migrate": "./src/migrate.ts" }, "scripts": { - "db": "drizzle-kit", - "setup": "drizzle-kit push --force", - "db:push": "drizzle-kit push --force", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - "db:studio": "drizzle-kit studio" + "db:generate": "bun --bun effect-orm generate", + "db:check": "bun --bun effect-orm check", + "db:migrate": "bun scripts/migrate.ts", + "db:status": "bun --bun effect-orm status", + "db:verify": "bun --bun effect-orm verify", + "setup": "bun scripts/migrate.ts", + "typecheck": "tsc --noEmit" }, "dependencies": { + "@effect/sql-pg": "catalog:effect", "@hazel/domain": "workspace:*", "@hazel/schema": "workspace:*", - "drizzle-orm": "^0.45.1", - "effect": "catalog:effect", - "postgres": "^3.4.7", "@maple-dev/effect-orm": "0.5.0", - "@effect/sql-pg": "catalog:effect" + "effect": "catalog:effect" }, "devDependencies": { "@testcontainers/postgresql": "^10.18.0", "@types/bun": "1.3.9", - "drizzle-kit": "^0.31.8", "typescript": "^5.9.3" } } diff --git a/packages/db/scripts/migrate.ts b/packages/db/scripts/migrate.ts new file mode 100644 index 000000000..04d266ffc --- /dev/null +++ b/packages/db/scripts/migrate.ts @@ -0,0 +1,32 @@ +/** + * Apply the bundled migrations to a Postgres. + * + * DATABASE_URL=postgres://… bun run db:migrate + * + * A database `drizzle-kit push` built (no effect-orm ledger yet) is adopted first: every migration up + * to the baseline is recorded as applied without running, then the rest run. + */ +import { Effect } from "effect" +import { migrateUrl } from "../src/migrate" + +const url = process.env.DATABASE_URL ?? "postgresql://user:password@localhost:5432/app" + +const program = migrateUrl(url).pipe( + Effect.tap(({ adopted, applied }) => + Effect.log( + [ + adopted.length > 0 + ? `Adopted the existing schema: ${adopted.join(", ")} recorded as applied` + : "", + applied.length === 0 ? "Up to date" : `Applied ${applied.map((m) => m.name).join(", ")}`, + ] + .filter(Boolean) + .join("\n"), + ), + ), +) + +Effect.runPromise(program).catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/packages/db/scripts/render-ddl.ts b/packages/db/scripts/render-ddl.ts index 480185f2e..35bc4297f 100644 --- a/packages/db/scripts/render-ddl.ts +++ b/packages/db/scripts/render-ddl.ts @@ -1,14 +1,20 @@ -// Prints the DDL the effect-orm tables describe, enum types first. +// Prints the DDL the effect-orm tables describe, enum types first: the body of the initial migration +// (`--> statement-breakpoint` between statements), and the schema tests create. import * as S from "@maple-dev/effect-orm/schema" import { Effect } from "effect" import * as Tables from "../src/tables" -const enums = Object.values(Tables).filter( +const exported: ReadonlyArray = Object.values(Tables) +const enums = exported.filter( (value): value is { enumName: string; enumValues: ReadonlyArray } => typeof value === "object" && value !== null && "enumName" in value, ) -for (const e of enums) { - console.log(`CREATE TYPE "${e.enumName}" AS ENUM (${e.enumValues.map((v) => `'${v}'`).join(", ")});`) -} -const tables = Object.values(Tables).filter(S.isSchemaObject) -for (const statement of S.renderPgSchema(Effect.runSync(S.pgEntitiesOf(tables)))) console.log(`${statement};`) +const statements = [ + ...enums.map( + (e) => `CREATE TYPE ${e.enumName} AS ENUM (${e.enumValues.map((v) => `'${v}'`).join(", ")});`, + ), + ...S.renderPgSchema(Effect.runSync(S.pgEntitiesOf(exported.filter(S.isSchemaObject)))).map( + (s) => `${s};`, + ), +] +console.log(statements.join("\n--> statement-breakpoint\n")) diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 53ceb2062..4496a227a 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,10 +1,9 @@ -export * from "drizzle-orm" -export * as schema from "./schema" -// Re-export embed types for direct import +export * as PG from "@maple-dev/effect-orm/postgres" +export * as schema from "./tables" export type { MessageEmbed, MessageEmbedAuthor, MessageEmbedField, MessageEmbedFooter, -} from "./schema/messages" +} from "./tables/messages" export * from "./services" diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts new file mode 100644 index 000000000..31e36560a --- /dev/null +++ b/packages/db/src/migrate.ts @@ -0,0 +1,81 @@ +/** + * The bundled migrations and how to apply them. + * + * `migrations/` holds one `_/migration.sql` per migration. The first, + * `initial_schema`, creates the schema as `drizzle-kit push` left it; `effect_orm_baseline` starts + * effect-orm's history there. Every later migration is written by `effect-orm generate` + * (`effect-orm.config.ts`), or by hand with `--custom` for what effect-orm does not model (enums). + */ +import { existsSync, readdirSync, readFileSync } from "node:fs" +import { dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import * as PgClient from "@effect/sql-pg/PgClient" +import * as Orm from "@maple-dev/effect-orm/database" +import * as Migrate from "@maple-dev/effect-orm/migrate" +import { postgresDialect } from "@maple-dev/effect-orm/postgres" +import { Effect, Layer, Redacted, Schema } from "effect" +import * as SqlClient from "effect/sql/SqlClient" + +/** The migration a database `drizzle-kit push` built is adopted at. */ +export const BASELINE_MIGRATION = "20261012222436_effect_orm_baseline" + +export const migrationsFolder = (): string => + resolve(dirname(fileURLToPath(import.meta.url)), "../migrations") + +/** The bundled migrations, ordered by the effect-orm runner. */ +export const bundledMigrations = Effect.suspend(() => { + const dir = migrationsFolder() + const names = readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + return Migrate.fromRecord( + Object.fromEntries( + names.map((name): [string, Migrate.MigrationInput] => { + const snapshotPath = join(dir, name, "snapshot.json") + return [ + name, + { + migration: readFileSync(join(dir, name, "migration.sql"), "utf8"), + kind: "sql", + ...(existsSync(snapshotPath) + ? { snapshot: readFileSync(snapshotPath, "utf8") } + : undefined), + }, + ] + }), + ), + ) +}) + +const Found = Schema.Struct({ schema: Schema.Boolean, ledger: Schema.Boolean }) + +/** + * A database whose schema `drizzle-kit push` created has tables and no effect-orm ledger: record + * every migration up to the baseline as applied, without running them. Returns what it recorded. + */ +export const adoptPushedSchema = Effect.gen(function* () { + const orm = Orm.fromSqlClient(yield* SqlClient.SqlClient, { dialect: postgresDialect }) + const [found] = yield* orm.query( + Orm.sql`SELECT to_regclass('public.users') IS NOT NULL AS schema, + to_regclass(${Migrate.LEDGER_TABLES.migrations}) IS NOT NULL AS ledger`, + Found, + ) + if (found === undefined || !found.schema || found.ledger) return [] + return yield* Migrate.baseline({ migrations: yield* bundledMigrations, upTo: BASELINE_MIGRATION }) +}) + +/** Adopts a pushed schema if needed, then applies every bundled migration the database lacks. */ +export const migrateBundled = Effect.gen(function* () { + const adopted = yield* adoptPushedSchema + const applied = yield* Migrate.run({ migrations: yield* bundledMigrations, strict: true }) + return { adopted, applied } +}) + +/** The migration driver over a fresh single-connection client to `url`. */ +export const driverLayer = (url: string) => + Migrate.layerSqlClient().pipe( + Layer.provideMerge(PgClient.layer({ url: Redacted.make(url), maxConnections: 1 })), + ) + +/** Applies the bundled migrations to the database at `url` (tests, local setup, scripts). */ +export const migrateUrl = (url: string) => migrateBundled.pipe(Effect.provide(driverLayer(url))) diff --git a/packages/db/src/schema/attachments.ts b/packages/db/src/schema/attachments.ts deleted file mode 100644 index 02966a223..000000000 --- a/packages/db/src/schema/attachments.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { AttachmentId, ChannelId, MessageId, OrganizationId, UserId } from "@hazel/schema" -import { index, integer, pgEnum, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core" - -// Attachment status -export const attachmentStatusEnum = pgEnum("attachment_status", ["uploading", "complete", "failed"]) - -// Attachments table -export const attachmentsTable = pgTable( - "attachments", - { - id: uuid("id").primaryKey().defaultRandom().$type(), - organizationId: uuid().notNull().$type(), - channelId: uuid().$type(), - messageId: uuid().$type(), - fileName: varchar({ length: 255 }).notNull(), - fileSize: integer().notNull(), - externalUrl: varchar({ length: 2048 }), - uploadedBy: uuid().notNull().$type(), - status: attachmentStatusEnum().notNull().default("uploading"), - uploadedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("attachments_organization_id_idx").on(table.organizationId), - index("attachments_channel_id_idx").on(table.channelId), - index("attachments_message_id_idx").on(table.messageId), - index("attachments_message_uploaded_at_idx").on(table.messageId, table.uploadedAt), - index("attachments_uploaded_by_idx").on(table.uploadedBy), - index("attachments_status_idx").on(table.status), - index("attachments_deleted_at_idx").on(table.deletedAt), - ], -) - -// Type exports -export type Attachment = typeof attachmentsTable.$inferSelect -export type NewAttachment = typeof attachmentsTable.$inferInsert diff --git a/packages/db/src/schema/bot-commands.ts b/packages/db/src/schema/bot-commands.ts deleted file mode 100644 index 858d02e3c..000000000 --- a/packages/db/src/schema/bot-commands.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { BotCommandId, BotId } from "@hazel/schema" -import { - boolean, - index, - jsonb, - pgTable, - text, - timestamp, - uniqueIndex, - uuid, - varchar, -} from "drizzle-orm/pg-core" - -/** - * Argument definition for a bot command - */ -export interface BotCommandArgument { - name: string - description?: string - required: boolean - placeholder?: string - type: "string" | "number" | "user" | "channel" -} - -/** - * Bot commands table - stores slash commands registered by bots - */ -export const botCommandsTable = pgTable( - "bot_commands", - { - id: uuid().primaryKey().defaultRandom().$type(), - botId: uuid().notNull().$type(), - name: varchar({ length: 50 }).notNull(), - description: text().notNull(), - arguments: jsonb().$type().default([]), - usageExample: text(), - isEnabled: boolean().notNull().default(true), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - index("bot_commands_bot_id_idx").on(table.botId), - uniqueIndex("bot_commands_bot_id_name_idx").on(table.botId, table.name), - ], -) - -// Type exports -export type BotCommand = typeof botCommandsTable.$inferSelect -export type NewBotCommand = typeof botCommandsTable.$inferInsert diff --git a/packages/db/src/schema/bot-installations.ts b/packages/db/src/schema/bot-installations.ts deleted file mode 100644 index e4b71f204..000000000 --- a/packages/db/src/schema/bot-installations.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { BotId, BotInstallationId, OrganizationId, UserId } from "@hazel/schema" -import { index, pgTable, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core" - -/** - * Bot installations table - tracks which bots are installed in which organizations - */ -export const botInstallationsTable = pgTable( - "bot_installations", - { - id: uuid().primaryKey().defaultRandom().$type(), - botId: uuid().notNull().$type(), - organizationId: uuid().notNull().$type(), - installedBy: uuid().notNull().$type(), - installedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - index("bot_installations_org_idx").on(table.organizationId), - index("bot_installations_bot_idx").on(table.botId), - uniqueIndex("bot_installations_bot_org_unique").on(table.botId, table.organizationId), - ], -) - -// Type exports -export type BotInstallation = typeof botInstallationsTable.$inferSelect -export type NewBotInstallation = typeof botInstallationsTable.$inferInsert diff --git a/packages/db/src/schema/bots.ts b/packages/db/src/schema/bots.ts deleted file mode 100644 index beeb25a2f..000000000 --- a/packages/db/src/schema/bots.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { BotId, UserId } from "@hazel/schema" -import { boolean, index, integer, jsonb, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core" -import type { IntegrationConnection } from "./integration-connections" - -type IntegrationProvider = IntegrationConnection["provider"] - -export const botsTable = pgTable( - "bots", - { - id: uuid().primaryKey().defaultRandom().$type(), - userId: uuid().notNull().unique().$type(), - createdBy: uuid().notNull().$type(), - name: varchar({ length: 100 }).notNull(), - description: text(), - webhookUrl: text(), - apiTokenHash: text().notNull(), - scopes: jsonb().$type(), - metadata: jsonb().$type>(), - isPublic: boolean().notNull().default(false), - installCount: integer().notNull().default(0), - // Whether this bot can be @mentioned in messages - mentionable: boolean().notNull().default(false), - // List of integration providers this bot is allowed to use (e.g., ["linear", "github"]) - allowedIntegrations: jsonb().$type().default([]), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("bots_user_id_idx").on(table.userId), - index("bots_created_by_idx").on(table.createdBy), - index("bots_is_public_idx").on(table.isPublic), - index("bots_deleted_at_idx").on(table.deletedAt), - ], -) - -// Type exports -export type Bot = typeof botsTable.$inferSelect -export type NewBot = typeof botsTable.$inferInsert diff --git a/packages/db/src/schema/channel-access.ts b/packages/db/src/schema/channel-access.ts deleted file mode 100644 index bc7fce17e..000000000 --- a/packages/db/src/schema/channel-access.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { ChannelId, OrganizationId, UserId } from "@hazel/schema" -import { index, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core" - -/** - * Materialized channel visibility for Electric-safe single-subquery filtering. - * Each row means userId can read channelId in organizationId. - */ -export const channelAccessTable = pgTable( - "channel_access", - { - userId: uuid().notNull().$type(), - channelId: uuid().notNull().$type(), - organizationId: uuid().notNull().$type(), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - primaryKey({ columns: [table.userId, table.channelId], name: "channel_access_user_channel_pk" }), - index("channel_access_user_id_idx").on(table.userId), - index("channel_access_channel_id_idx").on(table.channelId), - index("channel_access_organization_id_idx").on(table.organizationId), - index("channel_access_org_user_idx").on(table.organizationId, table.userId), - ], -) - -export type ChannelAccess = typeof channelAccessTable.$inferSelect -export type NewChannelAccess = typeof channelAccessTable.$inferInsert diff --git a/packages/db/src/schema/channel-sections.ts b/packages/db/src/schema/channel-sections.ts deleted file mode 100644 index bfb1738cd..000000000 --- a/packages/db/src/schema/channel-sections.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ChannelSectionId, OrganizationId } from "@hazel/schema" -import { index, integer, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core" - -// Channel sections table - organization-wide groupings for channels -export const channelSectionsTable = pgTable( - "channel_sections", - { - id: uuid().primaryKey().defaultRandom().$type(), - organizationId: uuid().notNull().$type(), - name: varchar({ length: 255 }).notNull(), - order: integer().notNull().default(0), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("channel_sections_organization_id_idx").on(table.organizationId), - index("channel_sections_order_idx").on(table.organizationId, table.order), - index("channel_sections_deleted_at_idx").on(table.deletedAt), - ], -) - -// Type exports -export type ChannelSection = typeof channelSectionsTable.$inferSelect -export type NewChannelSection = typeof channelSectionsTable.$inferInsert diff --git a/packages/db/src/schema/channel-webhooks.ts b/packages/db/src/schema/channel-webhooks.ts deleted file mode 100644 index ce0a34dfd..000000000 --- a/packages/db/src/schema/channel-webhooks.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { ChannelId, ChannelWebhookId, OrganizationId, UserId } from "@hazel/schema" -import { boolean, index, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core" - -export const channelWebhooksTable = pgTable( - "channel_webhooks", - { - id: uuid().primaryKey().defaultRandom().$type(), - channelId: uuid().notNull().$type(), - organizationId: uuid().notNull().$type(), - // Bot identity for this webhook (machine user) - botUserId: uuid().notNull().$type(), - // Webhook configuration - name: varchar({ length: 100 }).notNull(), - description: text(), - avatarUrl: text(), - // Token for authentication (SHA-256 hashed) - tokenHash: text().notNull(), - // Last 4 chars of token for display - tokenSuffix: varchar({ length: 4 }).notNull(), - // Whether the webhook is active - isEnabled: boolean().notNull().default(true), - // Audit fields - createdBy: uuid().notNull().$type(), - lastUsedAt: timestamp({ mode: "date", withTimezone: true }), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("channel_webhooks_channel_id_idx").on(table.channelId), - index("channel_webhooks_organization_id_idx").on(table.organizationId), - index("channel_webhooks_bot_user_id_idx").on(table.botUserId), - index("channel_webhooks_token_hash_idx").on(table.tokenHash), - index("channel_webhooks_deleted_at_idx").on(table.deletedAt), - ], -) - -// Type exports -export type ChannelWebhook = typeof channelWebhooksTable.$inferSelect -export type NewChannelWebhook = typeof channelWebhooksTable.$inferInsert diff --git a/packages/db/src/schema/channels.ts b/packages/db/src/schema/channels.ts deleted file mode 100644 index fdb778fc4..000000000 --- a/packages/db/src/schema/channels.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { - ChannelIcon, - ChannelId, - ChannelMemberId, - ChannelSectionId, - MessageId, - OrganizationId, - UserId, -} from "@hazel/schema" -import { sql } from "drizzle-orm" -import { - boolean, - index, - integer, - pgEnum, - pgTable, - timestamp, - uniqueIndex, - uuid, - varchar, -} from "drizzle-orm/pg-core" - -// Channel types -export const channelTypeEnum = pgEnum("channel_type", ["public", "private", "thread", "direct", "single"]) - -// Channels table -export const channelsTable = pgTable( - "channels", - { - id: uuid().primaryKey().defaultRandom().$type(), - name: varchar({ length: 255 }).notNull(), - icon: varchar({ length: 32 }).$type(), - type: channelTypeEnum().notNull(), - organizationId: uuid().notNull().$type(), - - parentChannelId: uuid().$type(), - sectionId: uuid().$type(), - - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("channels_organization_id_idx").on(table.organizationId), - index("channels_parent_channel_id_idx").on(table.parentChannelId), - index("channels_section_id_idx").on(table.sectionId), - index("channels_type_idx").on(table.type), - index("channels_deleted_at_idx").on(table.deletedAt), - ], -) - -// Channel members table - tracks user membership in channels -export const channelMembersTable = pgTable( - "channel_members", - { - id: uuid().primaryKey().defaultRandom().$type(), - channelId: uuid().notNull().$type(), - userId: uuid().notNull().$type(), - isHidden: boolean().notNull().default(false), - isMuted: boolean().notNull().default(false), - isFavorite: boolean().notNull().default(false), - lastSeenMessageId: uuid().$type(), - notificationCount: integer().notNull().default(0), - joinedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("channel_members_channel_id_idx").on(table.channelId), - index("channel_members_user_id_idx").on(table.userId), - index("channel_members_channel_user_idx").on(table.channelId, table.userId), - ], -) - -// Type exports -export type Channel = typeof channelsTable.$inferSelect -export type NewChannel = typeof channelsTable.$inferInsert -export type ChannelMember = typeof channelMembersTable.$inferSelect -export type NewChannelMember = typeof channelMembersTable.$inferInsert diff --git a/packages/db/src/schema/chat-sync.ts b/packages/db/src/schema/chat-sync.ts deleted file mode 100644 index 088aa8264..000000000 --- a/packages/db/src/schema/chat-sync.ts +++ /dev/null @@ -1,188 +0,0 @@ -import type { - ChannelId, - IntegrationConnectionId, - MessageId, - OrganizationId, - SyncChannelLinkId, - SyncConnectionId, - SyncEventReceiptId, - SyncMessageLinkId, - UserId, -} from "@hazel/schema" -import { sql } from "drizzle-orm" -import { - boolean, - index, - jsonb, - pgEnum, - pgTable, - text, - timestamp, - uniqueIndex, - uuid, - varchar, -} from "drizzle-orm/pg-core" - -export const chatSyncConnectionStatusEnum = pgEnum("chat_sync_connection_status", [ - "active", - "paused", - "error", - "disabled", -]) - -export const chatSyncDirectionEnum = pgEnum("chat_sync_direction", [ - "both", - "hazel_to_external", - "external_to_hazel", -]) - -export const chatSyncReceiptSourceEnum = pgEnum("chat_sync_receipt_source", ["hazel", "external"]) - -export const chatSyncReceiptStatusEnum = pgEnum("chat_sync_receipt_status", [ - "processed", - "ignored", - "failed", -]) - -export const chatSyncConnectionsTable = pgTable( - "chat_sync_connections", - { - id: uuid().primaryKey().defaultRandom().$type(), - organizationId: uuid().notNull().$type(), - // Optional so providers without OAuth/app installations can still be supported. - integrationConnectionId: uuid().$type(), - // Provider key (e.g. "discord", "slack"). Kept as string for forward compatibility. - provider: varchar({ length: 50 }).notNull(), - // Provider workspace/guild/team identifier. - externalWorkspaceId: varchar({ length: 255 }).notNull(), - externalWorkspaceName: varchar({ length: 255 }), - status: chatSyncConnectionStatusEnum().notNull().default("active"), - settings: jsonb().$type>(), - metadata: jsonb().$type>(), - errorMessage: text(), - lastSyncedAt: timestamp({ mode: "date", withTimezone: true }), - createdBy: uuid().notNull().$type(), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("chat_sync_conn_org_idx").on(table.organizationId), - index("chat_sync_conn_integration_idx").on(table.integrationConnectionId), - index("chat_sync_conn_provider_idx").on(table.provider), - index("chat_sync_conn_status_idx").on(table.status), - index("chat_sync_conn_deleted_at_idx").on(table.deletedAt), - uniqueIndex("chat_sync_conn_org_provider_workspace_unique") - .on(table.organizationId, table.provider, table.externalWorkspaceId) - .where(sql`${table.deletedAt} IS NULL`), - uniqueIndex("chat_sync_conn_integration_unique") - .on(table.integrationConnectionId) - .where(sql`${table.deletedAt} IS NULL AND ${table.integrationConnectionId} IS NOT NULL`), - ], -) - -export const chatSyncChannelLinksTable = pgTable( - "chat_sync_channel_links", - { - id: uuid().primaryKey().defaultRandom().$type(), - syncConnectionId: uuid().notNull().$type(), - hazelChannelId: uuid().notNull().$type(), - externalChannelId: varchar({ length: 255 }).notNull(), - externalChannelName: varchar({ length: 255 }), - direction: chatSyncDirectionEnum().notNull().default("both"), - isActive: boolean().notNull().default(true), - settings: jsonb().$type>(), - lastSyncedAt: timestamp({ mode: "date", withTimezone: true }), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("chat_sync_link_connection_idx").on(table.syncConnectionId), - index("chat_sync_link_hazel_channel_idx").on(table.hazelChannelId), - index("chat_sync_link_external_channel_idx").on(table.externalChannelId), - index("chat_sync_link_deleted_at_idx").on(table.deletedAt), - uniqueIndex("chat_sync_link_connection_hazel_unique") - .on(table.syncConnectionId, table.hazelChannelId) - .where(sql`${table.deletedAt} IS NULL`), - uniqueIndex("chat_sync_link_connection_external_unique") - .on(table.syncConnectionId, table.externalChannelId) - .where(sql`${table.deletedAt} IS NULL`), - ], -) - -export const chatSyncMessageLinksTable = pgTable( - "chat_sync_message_links", - { - id: uuid().primaryKey().defaultRandom().$type(), - channelLinkId: uuid().notNull().$type(), - hazelMessageId: uuid().notNull().$type(), - externalMessageId: varchar({ length: 255 }).notNull(), - source: chatSyncReceiptSourceEnum().notNull().default("hazel"), - // Optional root mapping enables thread/reply stitching across provider differences. - rootHazelMessageId: uuid().$type(), - rootExternalMessageId: varchar({ length: 255 }), - hazelThreadChannelId: uuid().$type(), - externalThreadId: varchar({ length: 255 }), - lastSyncedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("chat_sync_msg_link_channel_idx").on(table.channelLinkId), - index("chat_sync_msg_link_hazel_message_idx").on(table.hazelMessageId), - index("chat_sync_msg_link_external_message_idx").on(table.externalMessageId), - index("chat_sync_msg_link_root_hazel_idx").on(table.rootHazelMessageId), - index("chat_sync_msg_link_deleted_at_idx").on(table.deletedAt), - uniqueIndex("chat_sync_msg_link_hazel_unique") - .on(table.channelLinkId, table.hazelMessageId) - .where(sql`${table.deletedAt} IS NULL`), - uniqueIndex("chat_sync_msg_link_external_unique") - .on(table.channelLinkId, table.externalMessageId) - .where(sql`${table.deletedAt} IS NULL`), - ], -) - -export type ChatSyncOutboundIdentitySettings = { - readonly enabled: boolean - readonly strategy: "webhook" | "fallback_bot" - readonly providers: Record> -} - -export const chatSyncEventReceiptsTable = pgTable( - "chat_sync_event_receipts", - { - id: uuid().primaryKey().defaultRandom().$type(), - syncConnectionId: uuid().notNull().$type(), - channelLinkId: uuid().$type(), - source: chatSyncReceiptSourceEnum().notNull(), - externalEventId: varchar({ length: 255 }), - dedupeKey: varchar({ length: 255 }).notNull(), - payloadHash: varchar({ length: 128 }), - status: chatSyncReceiptStatusEnum().notNull().default("processed"), - errorMessage: text(), - processedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - index("chat_sync_receipt_connection_idx").on(table.syncConnectionId), - index("chat_sync_receipt_channel_link_idx").on(table.channelLinkId), - index("chat_sync_receipt_source_idx").on(table.source), - index("chat_sync_receipt_processed_at_idx").on(table.processedAt), - uniqueIndex("chat_sync_receipt_dedupe_unique").on( - table.syncConnectionId, - table.source, - table.dedupeKey, - ), - ], -) - -export type ChatSyncConnection = typeof chatSyncConnectionsTable.$inferSelect -export type NewChatSyncConnection = typeof chatSyncConnectionsTable.$inferInsert -export type ChatSyncChannelLink = typeof chatSyncChannelLinksTable.$inferSelect -export type NewChatSyncChannelLink = typeof chatSyncChannelLinksTable.$inferInsert -export type ChatSyncMessageLink = typeof chatSyncMessageLinksTable.$inferSelect -export type NewChatSyncMessageLink = typeof chatSyncMessageLinksTable.$inferInsert -export type ChatSyncEventReceipt = typeof chatSyncEventReceiptsTable.$inferSelect -export type NewChatSyncEventReceipt = typeof chatSyncEventReceiptsTable.$inferInsert diff --git a/packages/db/src/schema/connect.ts b/packages/db/src/schema/connect.ts deleted file mode 100644 index 007bd92d1..000000000 --- a/packages/db/src/schema/connect.ts +++ /dev/null @@ -1,154 +0,0 @@ -import type { - ChannelId, - ConnectConversationChannelId, - ConnectConversationId, - ConnectInviteId, - ConnectParticipantId, - OrganizationId, - UserId, -} from "@hazel/schema" -import { sql } from "drizzle-orm" -import { - boolean, - index, - jsonb, - pgEnum, - pgTable, - timestamp, - uniqueIndex, - uuid, - varchar, -} from "drizzle-orm/pg-core" - -export const connectConversationStatusEnum = pgEnum("connect_conversation_status", ["active", "disconnected"]) - -export const connectConversationChannelRoleEnum = pgEnum("connect_conversation_channel_role", [ - "host", - "guest", -]) - -export const connectInviteStatusEnum = pgEnum("connect_invite_status", [ - "pending", - "accepted", - "declined", - "revoked", - "expired", -]) - -export const connectInviteTargetKindEnum = pgEnum("connect_invite_target_kind", ["slug", "email"]) - -export const connectConversationsTable = pgTable( - "connect_conversations", - { - id: uuid().primaryKey().defaultRandom().$type(), - hostOrganizationId: uuid().notNull().$type(), - hostChannelId: uuid().notNull().$type(), - status: connectConversationStatusEnum().notNull().default("active"), - settings: jsonb().$type>(), - createdBy: uuid().notNull().$type(), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("connect_conversations_host_org_idx").on(table.hostOrganizationId), - index("connect_conversations_host_channel_idx").on(table.hostChannelId), - index("connect_conversations_status_idx").on(table.status), - index("connect_conversations_deleted_at_idx").on(table.deletedAt), - uniqueIndex("connect_conversations_host_channel_unique") - .on(table.hostChannelId) - .where(sql`${table.deletedAt} IS NULL`), - ], -) - -export const connectConversationChannelsTable = pgTable( - "connect_conversation_channels", - { - id: uuid().primaryKey().defaultRandom().$type(), - conversationId: uuid().notNull().$type(), - organizationId: uuid().notNull().$type(), - channelId: uuid().notNull().$type(), - role: connectConversationChannelRoleEnum().notNull(), - allowGuestMemberAdds: boolean().notNull().default(false), - isActive: boolean().notNull().default(true), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("connect_conv_channels_conversation_idx").on(table.conversationId), - index("connect_conv_channels_org_idx").on(table.organizationId), - index("connect_conv_channels_channel_idx").on(table.channelId), - index("connect_conv_channels_deleted_at_idx").on(table.deletedAt), - uniqueIndex("connect_conv_channels_channel_unique") - .on(table.channelId) - .where(sql`${table.deletedAt} IS NULL`), - uniqueIndex("connect_conv_channels_conversation_org_unique") - .on(table.conversationId, table.organizationId) - .where(sql`${table.deletedAt} IS NULL`), - ], -) - -export const connectInvitesTable = pgTable( - "connect_invites", - { - id: uuid().primaryKey().defaultRandom().$type(), - conversationId: uuid().notNull().$type(), - hostOrganizationId: uuid().notNull().$type(), - hostChannelId: uuid().notNull().$type(), - targetKind: connectInviteTargetKindEnum().notNull(), - targetValue: varchar({ length: 255 }).notNull(), - guestOrganizationId: uuid().$type(), - status: connectInviteStatusEnum().notNull().default("pending"), - allowGuestMemberAdds: boolean().notNull().default(false), - invitedBy: uuid().notNull().$type(), - acceptedBy: uuid().$type(), - acceptedAt: timestamp({ mode: "date", withTimezone: true }), - expiresAt: timestamp({ mode: "date", withTimezone: true }), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("connect_invites_conversation_idx").on(table.conversationId), - index("connect_invites_host_org_idx").on(table.hostOrganizationId), - index("connect_invites_guest_org_idx").on(table.guestOrganizationId), - index("connect_invites_status_idx").on(table.status), - index("connect_invites_target_idx").on(table.targetKind, table.targetValue), - index("connect_invites_deleted_at_idx").on(table.deletedAt), - ], -) - -export const connectParticipantsTable = pgTable( - "connect_participants", - { - id: uuid().primaryKey().defaultRandom().$type(), - conversationId: uuid().notNull().$type(), - channelId: uuid().notNull().$type(), - userId: uuid().notNull().$type(), - homeOrganizationId: uuid().notNull().$type(), - isExternal: boolean().notNull().default(false), - addedBy: uuid().$type(), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("connect_participants_conversation_idx").on(table.conversationId), - index("connect_participants_channel_idx").on(table.channelId), - index("connect_participants_user_idx").on(table.userId), - index("connect_participants_deleted_at_idx").on(table.deletedAt), - uniqueIndex("connect_participants_channel_user_unique") - .on(table.channelId, table.userId) - .where(sql`${table.deletedAt} IS NULL`), - ], -) - -export type ConnectConversation = typeof connectConversationsTable.$inferSelect -export type NewConnectConversation = typeof connectConversationsTable.$inferInsert -export type ConnectConversationChannel = typeof connectConversationChannelsTable.$inferSelect -export type NewConnectConversationChannel = typeof connectConversationChannelsTable.$inferInsert -export type ConnectInvite = typeof connectInvitesTable.$inferSelect -export type NewConnectInvite = typeof connectInvitesTable.$inferInsert -export type ConnectParticipant = typeof connectParticipantsTable.$inferSelect -export type NewConnectParticipant = typeof connectParticipantsTable.$inferInsert diff --git a/packages/db/src/schema/custom-emojis.ts b/packages/db/src/schema/custom-emojis.ts deleted file mode 100644 index 3045bc988..000000000 --- a/packages/db/src/schema/custom-emojis.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { CustomEmojiId, OrganizationId, UserId } from "@hazel/schema" -import { sql } from "drizzle-orm" -import { index, pgTable, text, timestamp, uniqueIndex, uuid, varchar } from "drizzle-orm/pg-core" - -// Custom emojis table -export const customEmojisTable = pgTable( - "custom_emojis", - { - id: uuid("id").primaryKey().defaultRandom().$type(), - organizationId: uuid().notNull().$type(), - name: varchar({ length: 64 }).notNull(), - imageUrl: text().notNull(), - createdBy: uuid().notNull().$type(), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("custom_emojis_organization_id_idx").on(table.organizationId), - index("custom_emojis_name_idx").on(table.name), - uniqueIndex("custom_emojis_org_name_unique_idx") - .on(table.organizationId, table.name) - .where(sql`${table.deletedAt} IS NULL`), - index("custom_emojis_deleted_at_idx").on(table.deletedAt), - ], -) - -// Type exports -export type CustomEmoji = typeof customEmojisTable.$inferSelect -export type NewCustomEmoji = typeof customEmojisTable.$inferInsert diff --git a/packages/db/src/schema/github-subscriptions.ts b/packages/db/src/schema/github-subscriptions.ts deleted file mode 100644 index fc65290a6..000000000 --- a/packages/db/src/schema/github-subscriptions.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { GitHubSubscription } from "@hazel/domain/models" -import type { ChannelId, GitHubSubscriptionId, OrganizationId, UserId } from "@hazel/schema" -import { sql } from "drizzle-orm" -import { - bigint, - boolean, - index, - jsonb, - pgTable, - timestamp, - uniqueIndex, - uuid, - varchar, -} from "drizzle-orm/pg-core" - -type GitHubEventType = GitHubSubscription.GitHubEventType - -export const githubSubscriptionsTable = pgTable( - "github_subscriptions", - { - id: uuid().primaryKey().defaultRandom().$type(), - channelId: uuid().notNull().$type(), - organizationId: uuid().notNull().$type(), - repositoryId: bigint({ mode: "number" }).notNull(), - repositoryFullName: varchar({ length: 255 }).notNull(), - repositoryOwner: varchar({ length: 255 }).notNull(), - repositoryName: varchar({ length: 255 }).notNull(), - enabledEvents: jsonb() - .$type() - .notNull() - .default(["push", "pull_request", "issues"]), - branchFilter: varchar({ length: 255 }), - isEnabled: boolean().notNull().default(true), - createdBy: uuid().notNull().$type(), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("github_subscriptions_channel_id_idx").on(table.channelId), - index("github_subscriptions_organization_id_idx").on(table.organizationId), - index("github_subscriptions_repository_id_idx").on(table.repositoryId), - index("github_subscriptions_deleted_at_idx").on(table.deletedAt), - // Unique constraint: one subscription per channel-repository pair (excluding soft-deleted) - uniqueIndex("github_subscriptions_channel_repo_unique") - .on(table.channelId, table.repositoryId) - .where(sql`${table.deletedAt} IS NULL`), - ], -) - -// Type exports -export type GitHubSubscription = typeof githubSubscriptionsTable.$inferSelect -export type NewGitHubSubscription = typeof githubSubscriptionsTable.$inferInsert diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts deleted file mode 100644 index c6489c749..000000000 --- a/packages/db/src/schema/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Export all tables - -export * from "@hazel/schema" -export * from "./attachments" -export * from "./bot-commands" -export * from "./bot-installations" -export * from "./bots" -export * from "./channel-access" -export * from "./channel-sections" -export * from "./channel-webhooks" -export * from "./channels" -export * from "./chat-sync" -export * from "./custom-emojis" -export * from "./connect" -export * from "./github-subscriptions" -export * from "./integration-connections" -export * from "./integration-requests" -export * from "./integration-tokens" -export * from "./message-outbox" -export * from "./messages" -export * from "./notifications" -export * from "./organizations" -export * from "./pinned-messages" -export * from "./rss-subscriptions" -// Export all relations -export * from "./relations" -export * from "./typing-indicators" -export * from "./user-presence-status" -export * from "./users" diff --git a/packages/db/src/schema/integration-connections.ts b/packages/db/src/schema/integration-connections.ts deleted file mode 100644 index 5f27abb96..000000000 --- a/packages/db/src/schema/integration-connections.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { IntegrationConnectionId, OrganizationId, UserId } from "@hazel/schema" -import { sql } from "drizzle-orm" -import { - index, - jsonb, - pgEnum, - pgTable, - text, - timestamp, - uniqueIndex, - uuid, - varchar, -} from "drizzle-orm/pg-core" - -export const connectionLevelEnum = pgEnum("connection_level", ["organization", "user"]) - -export const connectionStatusEnum = pgEnum("connection_status", [ - "active", - "expired", - "revoked", - "error", - "suspended", -]) - -export const integrationConnectionsTable = pgTable( - "integration_connections", - { - id: uuid().primaryKey().defaultRandom().$type(), - provider: varchar({ length: 50 }) - .notNull() - .$type<"linear" | "github" | "figma" | "notion" | "discord" | "craft">(), - organizationId: uuid().notNull().$type(), - userId: uuid().$type(), // null for org-level, populated for user-level - level: connectionLevelEnum().notNull(), - status: connectionStatusEnum().notNull().default("active"), - - // External account info - externalAccountId: varchar({ length: 255 }), - externalAccountName: varchar({ length: 255 }), - - // Who connected it - connectedBy: uuid().notNull().$type(), - - // Integration-specific settings (e.g., defaultTeamId for Linear) - user configurable - settings: jsonb().$type>(), - - // System-managed metadata (e.g., installationId for GitHub) - metadata: jsonb().$type>(), - - // Error details when status is 'error' - errorMessage: text(), - - lastUsedAt: timestamp({ mode: "date", withTimezone: true }), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("int_conn_org_idx").on(table.organizationId), - index("int_conn_provider_idx").on(table.provider), - index("int_conn_status_idx").on(table.status), - index("int_conn_deleted_at_idx").on(table.deletedAt), - uniqueIndex("int_conn_org_user_provider_unique").on( - table.organizationId, - table.userId, - table.provider, - ), - // Prevent linking the same external account to multiple users in the same org. - uniqueIndex("int_conn_org_provider_external_user_unique") - .on(table.organizationId, table.provider, table.externalAccountId) - .where( - sql`${table.deletedAt} IS NULL - AND ${table.level} = 'user' - AND ${table.externalAccountId} IS NOT NULL`, - ), - // Index for faster GitHub installation ID lookups (used in token refresh and callbacks) - index("int_conn_github_installation_idx").using("btree", sql`(${table.metadata}->>'installationId')`), - ], -) - -// Type exports -export type IntegrationConnection = typeof integrationConnectionsTable.$inferSelect -export type NewIntegrationConnection = typeof integrationConnectionsTable.$inferInsert diff --git a/packages/db/src/schema/integration-requests.ts b/packages/db/src/schema/integration-requests.ts deleted file mode 100644 index 368da6c08..000000000 --- a/packages/db/src/schema/integration-requests.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { IntegrationRequestId, OrganizationId, UserId } from "@hazel/schema" -import { index, pgEnum, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core" - -// Integration request status -export const integrationRequestStatusEnum = pgEnum("integration_request_status", [ - "pending", - "reviewed", - "planned", - "rejected", -]) - -// Integration requests table -export const integrationRequestsTable = pgTable( - "integration_requests", - { - id: uuid().primaryKey().defaultRandom().$type(), - organizationId: uuid().notNull().$type(), - requestedBy: uuid().notNull().$type(), - integrationName: varchar({ length: 255 }).notNull(), - integrationUrl: varchar({ length: 500 }), - description: text(), - status: integrationRequestStatusEnum().notNull().default("pending"), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - index("integration_requests_organization_id_idx").on(table.organizationId), - index("integration_requests_status_idx").on(table.status), - index("integration_requests_created_at_idx").on(table.createdAt), - ], -) - -// Type exports -export type IntegrationRequest = typeof integrationRequestsTable.$inferSelect -export type NewIntegrationRequest = typeof integrationRequestsTable.$inferInsert diff --git a/packages/db/src/schema/integration-tokens.ts b/packages/db/src/schema/integration-tokens.ts deleted file mode 100644 index 6d4357897..000000000 --- a/packages/db/src/schema/integration-tokens.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { IntegrationConnectionId, IntegrationTokenId } from "@hazel/schema" -import { index, integer, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core" - -export const integrationTokensTable = pgTable( - "integration_tokens", - { - id: uuid().primaryKey().defaultRandom().$type(), - connectionId: uuid().notNull().unique().$type(), - - // Encrypted token data (AES-256-GCM) - encryptedAccessToken: text().notNull(), - encryptedRefreshToken: text(), - iv: varchar({ length: 32 }).notNull(), // Base64 encoded initialization vector for access token - refreshTokenIv: varchar({ length: 32 }), // Base64 encoded initialization vector for refresh token - encryptionKeyVersion: integer().notNull().default(1), - - // Token metadata (not encrypted, needed for refresh logic) - tokenType: varchar({ length: 50 }).default("Bearer"), - scope: text(), // Granted OAuth scopes - expiresAt: timestamp({ mode: "date", withTimezone: true }), - refreshTokenExpiresAt: timestamp({ mode: "date", withTimezone: true }), - - // Audit fields - lastRefreshedAt: timestamp({ mode: "date", withTimezone: true }), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - index("int_tokens_connection_id_idx").on(table.connectionId), - index("int_tokens_expires_at_idx").on(table.expiresAt), - index("int_tokens_key_version_idx").on(table.encryptionKeyVersion), - ], -) - -// Type exports -export type IntegrationToken = typeof integrationTokensTable.$inferSelect -export type NewIntegrationToken = typeof integrationTokensTable.$inferInsert diff --git a/packages/db/src/schema/message-integration-links.ts b/packages/db/src/schema/message-integration-links.ts deleted file mode 100644 index 7446a5412..000000000 --- a/packages/db/src/schema/message-integration-links.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { IntegrationConnectionId, MessageId, MessageIntegrationLinkId } from "@hazel/schema" -import { index, jsonb, pgEnum, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core" - -export const linkTypeEnum = pgEnum("integration_link_type", ["created", "mentioned", "resolved", "linked"]) - -export const messageIntegrationLinksTable = pgTable( - "message_integration_links", - { - id: uuid().primaryKey().defaultRandom().$type(), - messageId: uuid().notNull().$type(), - connectionId: uuid().notNull().$type(), - provider: varchar({ length: 50 }) - .notNull() - .$type<"linear" | "github" | "figma" | "notion" | "discord" | "craft">(), - - // External item info - externalId: varchar({ length: 255 }).notNull(), - externalUrl: text().notNull(), - externalTitle: varchar({ length: 500 }), - - // Link type (how was this link created) - linkType: linkTypeEnum().notNull().default("linked"), - - // Provider-specific metadata (e.g., issue status, priority, etc.) - metadata: jsonb().$type>(), - - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - index("msg_int_link_message_idx").on(table.messageId), - index("msg_int_link_connection_idx").on(table.connectionId), - index("msg_int_link_external_idx").on(table.provider, table.externalId), - ], -) - -// Type exports -export type MessageIntegrationLink = typeof messageIntegrationLinksTable.$inferSelect -export type NewMessageIntegrationLink = typeof messageIntegrationLinksTable.$inferInsert diff --git a/packages/db/src/schema/message-outbox.ts b/packages/db/src/schema/message-outbox.ts deleted file mode 100644 index f3809898f..000000000 --- a/packages/db/src/schema/message-outbox.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { ChannelId, MessageOutboxEventId } from "@hazel/schema" -import { sql } from "drizzle-orm" -import { - bigint, - index, - integer, - jsonb, - pgEnum, - pgTable, - text, - timestamp, - uniqueIndex, - uuid, - varchar, -} from "drizzle-orm/pg-core" - -export const messageOutboxEventTypeEnum = pgEnum("message_outbox_event_type", [ - "message_created", - "message_updated", - "message_deleted", - "reaction_created", - "reaction_deleted", -]) - -export const messageOutboxEventStatusEnum = pgEnum("message_outbox_event_status", [ - "pending", - "processing", - "processed", - "failed", -]) - -export const messageOutboxEventsTable = pgTable( - "message_outbox_events", - { - id: uuid().primaryKey().defaultRandom().$type(), - sequence: bigint({ mode: "number" }).generatedAlwaysAsIdentity().notNull(), - eventType: messageOutboxEventTypeEnum().notNull(), - aggregateId: uuid().notNull(), - channelId: uuid().notNull().$type(), - payload: jsonb().$type>().notNull(), - status: messageOutboxEventStatusEnum().notNull().default("pending"), - attemptCount: integer().notNull().default(0), - availableAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - lockedAt: timestamp({ mode: "date", withTimezone: true }), - lockedBy: varchar({ length: 255 }), - lastError: text(), - processedAt: timestamp({ mode: "date", withTimezone: true }), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - uniqueIndex("message_outbox_events_sequence_unique").on(table.sequence), - index("message_outbox_events_status_available_sequence_idx").on( - table.status, - table.availableAt, - table.sequence, - ), - index("message_outbox_events_channel_sequence_idx").on(table.channelId, table.sequence), - index("message_outbox_events_created_at_idx").on(table.createdAt), - index("message_outbox_events_locked_at_idx") - .on(table.lockedAt) - .where(sql`${table.status} = 'processing'`), - ], -) - -export type MessageOutboxEvent = typeof messageOutboxEventsTable.$inferSelect -export type NewMessageOutboxEvent = typeof messageOutboxEventsTable.$inferInsert diff --git a/packages/db/src/schema/messages.ts b/packages/db/src/schema/messages.ts deleted file mode 100644 index 394d93f11..000000000 --- a/packages/db/src/schema/messages.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { ChannelId, ConnectConversationId, MessageId, MessageReactionId, UserId } from "@hazel/schema" -import { index, jsonb, pgTable, text, timestamp, unique, uuid, varchar } from "drizzle-orm/pg-core" - -// Embed types for JSONB column -export interface MessageEmbedAuthor { - name: string - url?: string - iconUrl?: string -} - -export interface MessageEmbedFooter { - text: string - iconUrl?: string -} - -export interface MessageEmbedField { - name: string - value: string - inline?: boolean -} - -export interface MessageEmbedBadge { - text: string - color?: number // Hex color as integer -} - -/** - * Cached snapshot of live state for non-realtime clients. - * Synced on finalize when the actor completes. - */ -export interface MessageEmbedLiveStateCached { - status: "idle" | "active" | "completed" | "failed" - data: Record - text?: string - progress?: number - error?: string -} - -/** - * Live state configuration for messages with attached RivetKit actors. - * Used for real-time updates like deployment status or AI streaming. - */ -export interface MessageEmbedLiveState { - // Whether this message has an attached actor - enabled: true - // Cached snapshot for non-realtime clients (synced on finalize) - cached?: MessageEmbedLiveStateCached -} - -export interface MessageEmbed { - title?: string - description?: string - url?: string - color?: number // Hex color as integer - author?: MessageEmbedAuthor - footer?: MessageEmbedFooter - image?: { url: string } - thumbnail?: { url: string } - fields?: MessageEmbedField[] - timestamp?: string // ISO 8601 timestamp - badge?: MessageEmbedBadge // Status badge (e.g., "Deployed", "Failed") - liveState?: MessageEmbedLiveState // Live state for real-time updates -} - -// Messages table -export const messagesTable = pgTable( - "messages", - { - id: uuid().primaryKey().defaultRandom().$type(), - channelId: uuid().notNull().$type(), - conversationId: uuid().$type(), - authorId: uuid().notNull().$type(), - content: text().notNull(), - // Rich embeds for webhook messages (Discord-style) - embeds: jsonb().$type(), - // Reply to another message - replyToMessageId: uuid().$type(), - // Thread channel (if this message started a thread) - threadChannelId: uuid().$type(), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("messages_channel_id_idx").on(table.channelId), - index("messages_conversation_id_idx").on(table.conversationId), - index("messages_author_id_idx").on(table.authorId), - index("messages_reply_to_idx").on(table.replyToMessageId), - index("messages_thread_channel_idx").on(table.threadChannelId), - index("messages_created_at_idx").on(table.createdAt), - index("messages_deleted_at_idx").on(table.deletedAt), - ], -) - -// Message reactions table (normalized from array in Convex) -export const messageReactionsTable = pgTable( - "message_reactions", - { - id: uuid().primaryKey().defaultRandom().$type(), - messageId: uuid().notNull().$type(), - channelId: uuid().notNull().$type(), - conversationId: uuid().$type(), - userId: uuid().notNull().$type(), - emoji: varchar({ length: 50 }).notNull(), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - index("reactions_channel_id_idx").on(table.channelId), - index("reactions_conversation_id_idx").on(table.conversationId), - index("reactions_message_id_idx").on(table.messageId), - index("reactions_user_id_idx").on(table.userId), - index("reactions_message_user_emoji_idx").on(table.messageId, table.userId, table.emoji), - unique("reactions_message_user_emoji_unique").on(table.messageId, table.userId, table.emoji), - ], -) - -// Message attachments junction table removed - attachments now directly reference messageId - -// Type exports -export type Message = typeof messagesTable.$inferSelect -export type NewMessage = typeof messagesTable.$inferInsert -export type MessageReaction = typeof messageReactionsTable.$inferSelect -export type NewMessageReaction = typeof messageReactionsTable.$inferInsert diff --git a/packages/db/src/schema/notifications.ts b/packages/db/src/schema/notifications.ts deleted file mode 100644 index bcd441579..000000000 --- a/packages/db/src/schema/notifications.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { NotificationId, OrganizationMemberId } from "@hazel/schema" -import { sql } from "drizzle-orm" -import { index, pgTable, timestamp, uniqueIndex, uuid, varchar } from "drizzle-orm/pg-core" - -// Notifications table -export const notificationsTable = pgTable( - "notifications", - { - id: uuid().primaryKey().defaultRandom().$type(), - memberId: uuid().notNull().$type(), - targetedResourceId: uuid(), - targetedResourceType: varchar({ length: 50 }), // 'channel' or 'organization' - // Can be a message - resourceId: uuid(), - resourceType: varchar({ length: 50 }), // 'message' - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - readAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("notifications_member_id_idx").on(table.memberId), - index("notifications_member_created_id_idx").on(table.memberId, table.createdAt, table.id), - index("notifications_targeted_resource_idx").on(table.targetedResourceId, table.targetedResourceType), - index("notifications_resource_idx").on(table.resourceId, table.resourceType), - index("notifications_read_at_idx").on(table.readAt), - uniqueIndex("notifications_message_channel_dedupe_idx") - .on(table.memberId, table.resourceId, table.targetedResourceId) - .where(sql`${table.resourceType} = 'message' AND ${table.targetedResourceType} = 'channel'`), - ], -) - -// Type exports -export type Notification = typeof notificationsTable.$inferSelect -export type NewNotification = typeof notificationsTable.$inferInsert diff --git a/packages/db/src/schema/organizations.ts b/packages/db/src/schema/organizations.ts deleted file mode 100644 index 3c219b6b1..000000000 --- a/packages/db/src/schema/organizations.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" -import { sql } from "drizzle-orm" -import { - boolean, - index, - jsonb, - pgEnum, - pgTable, - text, - timestamp, - unique, - uniqueIndex, - uuid, - varchar, -} from "drizzle-orm/pg-core" - -// Organization member roles -export const organizationRoleEnum = pgEnum("organization_role", ["admin", "member", "owner"]) - -// Organizations table -export const organizationsTable = pgTable( - "organizations", - { - id: uuid().primaryKey().defaultRandom().$type(), - name: varchar({ length: 255 }).notNull(), - slug: varchar({ length: 100 }).unique(), - logoUrl: text(), - settings: jsonb(), - isPublic: boolean().notNull().default(false), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("organizations_slug_idx").on(table.slug), - index("organizations_deleted_at_idx").on(table.deletedAt), - // Defense-in-depth against the dedup-then-resync regression: even if a sync code path - // forgets to look up by Clerk org ID first, the database itself rejects two live rows - // claiming the same Clerk org. Partial so soft-deleted rows don't block re-creation. - uniqueIndex("organizations_clerk_org_id_unique") - .using("btree", sql`((${table.settings}->>'clerkOrganizationId'))`) - .where(sql`${table.deletedAt} IS NULL AND ${table.settings}->>'clerkOrganizationId' IS NOT NULL`), - ], -) - -// Organization members junction table -export const organizationMembersTable = pgTable( - "organization_members", - { - id: uuid().primaryKey().defaultRandom().$type(), - organizationId: uuid() - .notNull() - .references(() => organizationsTable.id, { onDelete: "cascade" }) - .$type(), - userId: uuid().notNull().$type(), - role: organizationRoleEnum().notNull().default("member"), - nickname: varchar({ length: 100 }), - joinedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - invitedBy: uuid().$type(), - metadata: jsonb(), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("org_members_organization_id_idx").on(table.organizationId), - index("org_members_user_id_idx").on(table.userId), - unique("org_members_org_user_unique").on(table.organizationId, table.userId), - ], -) - -// Type exports -export type Organization = typeof organizationsTable.$inferSelect -export type NewOrganization = typeof organizationsTable.$inferInsert -export type OrganizationMember = typeof organizationMembersTable.$inferSelect -export type NewOrganizationMember = typeof organizationMembersTable.$inferInsert diff --git a/packages/db/src/schema/pinned-messages.ts b/packages/db/src/schema/pinned-messages.ts deleted file mode 100644 index e57e955d3..000000000 --- a/packages/db/src/schema/pinned-messages.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { ChannelId, MessageId, PinnedMessageId, UserId } from "@hazel/schema" -import { index, pgTable, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core" - -// Pinned messages table (normalized from array in Convex) -export const pinnedMessagesTable = pgTable( - "pinned_messages", - { - id: uuid().primaryKey().defaultRandom().$type(), - channelId: uuid().notNull().$type(), - messageId: uuid().notNull().$type(), - pinnedBy: uuid().notNull().$type(), - pinnedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - index("pinned_messages_channel_id_idx").on(table.channelId), - index("pinned_messages_message_id_idx").on(table.messageId), - // A message can only be pinned once per channel - uniqueIndex("pinned_messages_unique_idx").on(table.channelId, table.messageId), - ], -) - -// Type exports -export type PinnedMessage = typeof pinnedMessagesTable.$inferSelect -export type NewPinnedMessage = typeof pinnedMessagesTable.$inferInsert diff --git a/packages/db/src/schema/relations.ts b/packages/db/src/schema/relations.ts deleted file mode 100644 index aa06a4b2f..000000000 --- a/packages/db/src/schema/relations.ts +++ /dev/null @@ -1,408 +0,0 @@ -import { relations } from "drizzle-orm" -import { attachmentsTable } from "./attachments" -import { customEmojisTable } from "./custom-emojis" -import { botCommandsTable } from "./bot-commands" -import { botInstallationsTable } from "./bot-installations" -import { botsTable } from "./bots" -import { channelAccessTable } from "./channel-access" -import { channelWebhooksTable } from "./channel-webhooks" -import { channelMembersTable, channelsTable } from "./channels" -import { - chatSyncChannelLinksTable, - chatSyncConnectionsTable, - chatSyncEventReceiptsTable, - chatSyncMessageLinksTable, -} from "./chat-sync" -import { rssSubscriptionsTable } from "./rss-subscriptions" -import { integrationConnectionsTable } from "./integration-connections" -import { integrationTokensTable } from "./integration-tokens" -import { messageReactionsTable, messagesTable } from "./messages" -import { notificationsTable } from "./notifications" -import { organizationMembersTable, organizationsTable } from "./organizations" -import { pinnedMessagesTable } from "./pinned-messages" -import { typingIndicatorsTable } from "./typing-indicators" -import { usersTable } from "./users" - -// Users relations -export const usersRelations = relations(usersTable, ({ many }) => ({ - organizationMembers: many(organizationMembersTable), - channelMembers: many(channelMembersTable), - channelAccess: many(channelAccessTable), - messages: many(messagesTable), - messageReactions: many(messageReactionsTable), - chatSyncConnectionsCreated: many(chatSyncConnectionsTable), - attachments: many(attachmentsTable), - pinnedMessages: many(pinnedMessagesTable), -})) - -// Organizations relations -export const organizationsRelations = relations(organizationsTable, ({ many }) => ({ - members: many(organizationMembersTable), - channels: many(channelsTable), - chatSyncConnections: many(chatSyncConnectionsTable), - channelAccess: many(channelAccessTable), - attachments: many(attachmentsTable), - customEmojis: many(customEmojisTable), -})) - -// Organization members relations -export const organizationMembersRelations = relations(organizationMembersTable, ({ one, many }) => ({ - organization: one(organizationsTable, { - fields: [organizationMembersTable.organizationId], - references: [organizationsTable.id], - }), - user: one(usersTable, { - fields: [organizationMembersTable.userId], - references: [usersTable.id], - }), - invitedByUser: one(usersTable, { - fields: [organizationMembersTable.invitedBy], - references: [usersTable.id], - }), - notifications: many(notificationsTable), - typingIndicators: many(typingIndicatorsTable), -})) - -// Channels relations -export const channelsRelations = relations(channelsTable, ({ one, many }) => ({ - organization: one(organizationsTable, { - fields: [channelsTable.organizationId], - references: [organizationsTable.id], - }), - parentChannel: one(channelsTable, { - fields: [channelsTable.parentChannelId], - references: [channelsTable.id], - relationName: "channelThreads", - }), - childChannels: many(channelsTable, { - relationName: "channelThreads", - }), - members: many(channelMembersTable), - messages: many(messagesTable), - threadMessages: many(messagesTable), - pinnedMessages: many(pinnedMessagesTable), - attachments: many(attachmentsTable), - typingIndicators: many(typingIndicatorsTable), - webhooks: many(channelWebhooksTable), - rssSubscriptions: many(rssSubscriptionsTable), - chatSyncLinks: many(chatSyncChannelLinksTable), - chatSyncThreadMessageLinks: many(chatSyncMessageLinksTable, { - relationName: "chatSyncHazelThreadChannelLinks", - }), - channelAccess: many(channelAccessTable), -})) - -// Channel members relations -export const channelMembersRelations = relations(channelMembersTable, ({ one }) => ({ - channel: one(channelsTable, { - fields: [channelMembersTable.channelId], - references: [channelsTable.id], - }), - user: one(usersTable, { - fields: [channelMembersTable.userId], - references: [usersTable.id], - }), - lastSeenMessage: one(messagesTable, { - fields: [channelMembersTable.lastSeenMessageId], - references: [messagesTable.id], - }), -})) - -// Channel access relations -export const channelAccessRelations = relations(channelAccessTable, ({ one }) => ({ - user: one(usersTable, { - fields: [channelAccessTable.userId], - references: [usersTable.id], - }), - channel: one(channelsTable, { - fields: [channelAccessTable.channelId], - references: [channelsTable.id], - }), - organization: one(organizationsTable, { - fields: [channelAccessTable.organizationId], - references: [organizationsTable.id], - }), -})) - -// Messages relations -export const messagesRelations = relations(messagesTable, ({ one, many }) => ({ - channel: one(channelsTable, { - fields: [messagesTable.channelId], - references: [channelsTable.id], - }), - author: one(usersTable, { - fields: [messagesTable.authorId], - references: [usersTable.id], - }), - replyTo: one(messagesTable, { - fields: [messagesTable.replyToMessageId], - references: [messagesTable.id], - relationName: "messageReplies", - }), - threadChannel: one(channelsTable, { - fields: [messagesTable.threadChannelId], - references: [channelsTable.id], - }), - replies: many(messagesTable, { - relationName: "messageReplies", - }), - reactions: many(messageReactionsTable), - chatSyncLinks: many(chatSyncMessageLinksTable, { - relationName: "chatSyncHazelMessageLinks", - }), - chatSyncRootLinks: many(chatSyncMessageLinksTable, { - relationName: "chatSyncRootHazelMessageLinks", - }), - attachments: many(attachmentsTable), - pinnedIn: many(pinnedMessagesTable), - seenBy: many(channelMembersTable), -})) - -// Message reactions relations -export const messageReactionsRelations = relations(messageReactionsTable, ({ one }) => ({ - message: one(messagesTable, { - fields: [messageReactionsTable.messageId], - references: [messagesTable.id], - }), - user: one(usersTable, { - fields: [messageReactionsTable.userId], - references: [usersTable.id], - }), -})) - -// Attachments relations -export const attachmentsRelations = relations(attachmentsTable, ({ one }) => ({ - organization: one(organizationsTable, { - fields: [attachmentsTable.organizationId], - references: [organizationsTable.id], - }), - channel: one(channelsTable, { - fields: [attachmentsTable.channelId], - references: [channelsTable.id], - }), - message: one(messagesTable, { - fields: [attachmentsTable.messageId], - references: [messagesTable.id], - }), - uploadedBy: one(usersTable, { - fields: [attachmentsTable.uploadedBy], - references: [usersTable.id], - }), -})) - -// Pinned messages relations -export const pinnedMessagesRelations = relations(pinnedMessagesTable, ({ one }) => ({ - channel: one(channelsTable, { - fields: [pinnedMessagesTable.channelId], - references: [channelsTable.id], - }), - message: one(messagesTable, { - fields: [pinnedMessagesTable.messageId], - references: [messagesTable.id], - }), - pinnedBy: one(usersTable, { - fields: [pinnedMessagesTable.pinnedBy], - references: [usersTable.id], - }), -})) - -// Notifications relations -export const notificationsRelations = relations(notificationsTable, ({ one }) => ({ - member: one(organizationMembersTable, { - fields: [notificationsTable.memberId], - references: [organizationMembersTable.id], - }), - // Note: targetedResourceId and resourceId can reference different tables - // These would need to be handled at the application level since they're polymorphic -})) - -// Typing indicators relations -export const typingIndicatorsRelations = relations(typingIndicatorsTable, ({ one }) => ({ - channel: one(channelsTable, { - fields: [typingIndicatorsTable.channelId], - references: [channelsTable.id], - }), - member: one(organizationMembersTable, { - fields: [typingIndicatorsTable.memberId], - references: [organizationMembersTable.id], - }), -})) - -// Integration connections relations -export const integrationConnectionsRelations = relations(integrationConnectionsTable, ({ one, many }) => ({ - organization: one(organizationsTable, { - fields: [integrationConnectionsTable.organizationId], - references: [organizationsTable.id], - }), - user: one(usersTable, { - fields: [integrationConnectionsTable.userId], - references: [usersTable.id], - }), - connectedByUser: one(usersTable, { - fields: [integrationConnectionsTable.connectedBy], - references: [usersTable.id], - }), - token: one(integrationTokensTable), - chatSyncConnections: many(chatSyncConnectionsTable), -})) - -// Integration tokens relations -export const integrationTokensRelations = relations(integrationTokensTable, ({ one }) => ({ - connection: one(integrationConnectionsTable, { - fields: [integrationTokensTable.connectionId], - references: [integrationConnectionsTable.id], - }), -})) - -// Chat sync connections relations -export const chatSyncConnectionsRelations = relations(chatSyncConnectionsTable, ({ one, many }) => ({ - organization: one(organizationsTable, { - fields: [chatSyncConnectionsTable.organizationId], - references: [organizationsTable.id], - }), - integrationConnection: one(integrationConnectionsTable, { - fields: [chatSyncConnectionsTable.integrationConnectionId], - references: [integrationConnectionsTable.id], - }), - createdByUser: one(usersTable, { - fields: [chatSyncConnectionsTable.createdBy], - references: [usersTable.id], - }), - channelLinks: many(chatSyncChannelLinksTable), - eventReceipts: many(chatSyncEventReceiptsTable), -})) - -// Chat sync channel links relations -export const chatSyncChannelLinksRelations = relations(chatSyncChannelLinksTable, ({ one, many }) => ({ - syncConnection: one(chatSyncConnectionsTable, { - fields: [chatSyncChannelLinksTable.syncConnectionId], - references: [chatSyncConnectionsTable.id], - }), - hazelChannel: one(channelsTable, { - fields: [chatSyncChannelLinksTable.hazelChannelId], - references: [channelsTable.id], - }), - messageLinks: many(chatSyncMessageLinksTable), - eventReceipts: many(chatSyncEventReceiptsTable), -})) - -// Chat sync message links relations -export const chatSyncMessageLinksRelations = relations(chatSyncMessageLinksTable, ({ one }) => ({ - channelLink: one(chatSyncChannelLinksTable, { - fields: [chatSyncMessageLinksTable.channelLinkId], - references: [chatSyncChannelLinksTable.id], - }), - hazelMessage: one(messagesTable, { - fields: [chatSyncMessageLinksTable.hazelMessageId], - references: [messagesTable.id], - relationName: "chatSyncHazelMessageLinks", - }), - rootHazelMessage: one(messagesTable, { - fields: [chatSyncMessageLinksTable.rootHazelMessageId], - references: [messagesTable.id], - relationName: "chatSyncRootHazelMessageLinks", - }), - hazelThreadChannel: one(channelsTable, { - fields: [chatSyncMessageLinksTable.hazelThreadChannelId], - references: [channelsTable.id], - relationName: "chatSyncHazelThreadChannelLinks", - }), -})) - -// Chat sync event receipts relations -export const chatSyncEventReceiptsRelations = relations(chatSyncEventReceiptsTable, ({ one }) => ({ - syncConnection: one(chatSyncConnectionsTable, { - fields: [chatSyncEventReceiptsTable.syncConnectionId], - references: [chatSyncConnectionsTable.id], - }), - channelLink: one(chatSyncChannelLinksTable, { - fields: [chatSyncEventReceiptsTable.channelLinkId], - references: [chatSyncChannelLinksTable.id], - }), -})) - -// Bots relations -export const botsRelations = relations(botsTable, ({ one, many }) => ({ - user: one(usersTable, { - fields: [botsTable.userId], - references: [usersTable.id], - }), - createdByUser: one(usersTable, { - fields: [botsTable.createdBy], - references: [usersTable.id], - }), - commands: many(botCommandsTable), - installations: many(botInstallationsTable), -})) - -// Bot commands relations -export const botCommandsRelations = relations(botCommandsTable, ({ one }) => ({ - bot: one(botsTable, { - fields: [botCommandsTable.botId], - references: [botsTable.id], - }), -})) - -// Bot installations relations -export const botInstallationsRelations = relations(botInstallationsTable, ({ one }) => ({ - bot: one(botsTable, { - fields: [botInstallationsTable.botId], - references: [botsTable.id], - }), - organization: one(organizationsTable, { - fields: [botInstallationsTable.organizationId], - references: [organizationsTable.id], - }), - installedByUser: one(usersTable, { - fields: [botInstallationsTable.installedBy], - references: [usersTable.id], - }), -})) - -// Channel webhooks relations -export const channelWebhooksRelations = relations(channelWebhooksTable, ({ one }) => ({ - channel: one(channelsTable, { - fields: [channelWebhooksTable.channelId], - references: [channelsTable.id], - }), - organization: one(organizationsTable, { - fields: [channelWebhooksTable.organizationId], - references: [organizationsTable.id], - }), - botUser: one(usersTable, { - fields: [channelWebhooksTable.botUserId], - references: [usersTable.id], - }), - createdByUser: one(usersTable, { - fields: [channelWebhooksTable.createdBy], - references: [usersTable.id], - }), -})) - -// RSS subscriptions relations -export const rssSubscriptionsRelations = relations(rssSubscriptionsTable, ({ one }) => ({ - channel: one(channelsTable, { - fields: [rssSubscriptionsTable.channelId], - references: [channelsTable.id], - }), - organization: one(organizationsTable, { - fields: [rssSubscriptionsTable.organizationId], - references: [organizationsTable.id], - }), - createdByUser: one(usersTable, { - fields: [rssSubscriptionsTable.createdBy], - references: [usersTable.id], - }), -})) - -// Custom emojis relations -export const customEmojisRelations = relations(customEmojisTable, ({ one }) => ({ - organization: one(organizationsTable, { - fields: [customEmojisTable.organizationId], - references: [organizationsTable.id], - }), - createdByUser: one(usersTable, { - fields: [customEmojisTable.createdBy], - references: [usersTable.id], - }), -})) diff --git a/packages/db/src/schema/rss-subscriptions.ts b/packages/db/src/schema/rss-subscriptions.ts deleted file mode 100644 index edcac6f33..000000000 --- a/packages/db/src/schema/rss-subscriptions.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { ChannelId, MessageId, OrganizationId, RssSubscriptionId, UserId } from "@hazel/schema" -import { sql } from "drizzle-orm" -import { - boolean, - index, - integer, - pgTable, - text, - timestamp, - uniqueIndex, - uuid, - varchar, -} from "drizzle-orm/pg-core" - -export const rssSubscriptionsTable = pgTable( - "rss_subscriptions", - { - id: uuid().primaryKey().defaultRandom().$type(), - channelId: uuid().notNull().$type(), - organizationId: uuid().notNull().$type(), - feedUrl: text().notNull(), - feedTitle: varchar({ length: 255 }), - feedIconUrl: text(), - lastFetchedAt: timestamp({ mode: "date", withTimezone: true }), - lastItemPublishedAt: timestamp({ mode: "date", withTimezone: true }), - lastItemGuid: text(), - consecutiveErrors: integer().notNull().default(0), - lastErrorMessage: text(), - lastErrorAt: timestamp({ mode: "date", withTimezone: true }), - isEnabled: boolean().notNull().default(true), - pollingIntervalMinutes: integer().notNull().default(15), - createdBy: uuid().notNull().$type(), - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("rss_subscriptions_channel_id_idx").on(table.channelId), - index("rss_subscriptions_organization_id_idx").on(table.organizationId), - index("rss_subscriptions_deleted_at_idx").on(table.deletedAt), - index("rss_subscriptions_poll_idx").on(table.isEnabled, table.lastFetchedAt, table.deletedAt), - // Unique constraint: one subscription per channel-feed pair (excluding soft-deleted) - uniqueIndex("rss_subscriptions_channel_feed_unique") - .on(table.channelId, table.feedUrl) - .where(sql`${table.deletedAt} IS NULL`), - ], -) - -export const rssPostedItemsTable = pgTable( - "rss_posted_items", - { - id: uuid().primaryKey().defaultRandom(), - subscriptionId: uuid().notNull().$type(), - itemGuid: text().notNull(), - itemUrl: text(), - messageId: uuid().$type(), - postedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - index("rss_posted_items_subscription_id_idx").on(table.subscriptionId), - uniqueIndex("rss_posted_items_sub_guid_unique").on(table.subscriptionId, table.itemGuid), - ], -) - -// Type exports -export type RssSubscription = typeof rssSubscriptionsTable.$inferSelect -export type NewRssSubscription = typeof rssSubscriptionsTable.$inferInsert -export type RssPostedItem = typeof rssPostedItemsTable.$inferSelect -export type NewRssPostedItem = typeof rssPostedItemsTable.$inferInsert diff --git a/packages/db/src/schema/typing-indicators.ts b/packages/db/src/schema/typing-indicators.ts deleted file mode 100644 index e62dbcd7f..000000000 --- a/packages/db/src/schema/typing-indicators.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ChannelId, ChannelMemberId, TypingIndicatorId } from "@hazel/schema" -import { bigint, index, pgTable, uniqueIndex, uuid } from "drizzle-orm/pg-core" - -// Typing indicators table - ephemeral data for real-time typing status -export const typingIndicatorsTable = pgTable( - "typing_indicators", - { - id: uuid().primaryKey().defaultRandom().$type(), - channelId: uuid().notNull().$type(), - memberId: uuid().notNull().$type(), - lastTyped: bigint({ mode: "number" }).notNull(), - }, - (table) => [ - uniqueIndex("typing_indicators_channel_member_unique").on(table.channelId, table.memberId), - index("typing_indicators_channel_timestamp_idx").on(table.channelId, table.lastTyped), - index("typing_indicators_timestamp_idx").on(table.lastTyped), - ], -) - -// Type exports -export type TypingIndicator = typeof typingIndicatorsTable.$inferSelect -export type NewTypingIndicator = typeof typingIndicatorsTable.$inferInsert diff --git a/packages/db/src/schema/user-presence-status.ts b/packages/db/src/schema/user-presence-status.ts deleted file mode 100644 index 3a6cc57c0..000000000 --- a/packages/db/src/schema/user-presence-status.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ChannelId, UserId, UserPresenceStatusId } from "@hazel/schema" -import { boolean, index, pgEnum, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core" - -// User presence status enum - user-settable statuses -export const userPresenceStatusEnum = pgEnum("user_presence_status_enum", [ - "online", - "away", - "busy", - "dnd", // do not disturb - "offline", -]) - -// User presence status table - for tracking user-set status overrides -export const userPresenceStatusTable = pgTable( - "user_presence_status", - { - id: uuid().primaryKey().defaultRandom().$type(), - userId: uuid().notNull().$type().unique(), - status: userPresenceStatusEnum().notNull().default("online"), - customMessage: varchar({ length: 255 }), // Optional custom status message - statusEmoji: varchar({ length: 10 }), // Single emoji for status (e.g., "🏠") - statusExpiresAt: timestamp({ mode: "date", withTimezone: true }), // Auto-clear time for custom status - activeChannelId: uuid().$type(), // Currently active/viewing channel - suppressNotifications: boolean().notNull().default(false), // Suppress notifications while status is set - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - lastSeenAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), // Track heartbeat for timeout-based offline detection - }, - (table) => [ - index("user_presence_status_user_id_idx").on(table.userId), - index("user_presence_status_active_channel_idx").on(table.activeChannelId), - index("user_presence_status_last_seen_idx").on(table.lastSeenAt), // For efficient stale user queries - ], -) - -// Type exports -export type UserPresenceStatus = typeof userPresenceStatusTable.$inferSelect -export type NewUserPresenceStatus = typeof userPresenceStatusTable.$inferInsert diff --git a/packages/db/src/schema/users.ts b/packages/db/src/schema/users.ts deleted file mode 100644 index aac30729a..000000000 --- a/packages/db/src/schema/users.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { UserId } from "@hazel/schema" -import { boolean, index, jsonb, pgEnum, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core" - -export const userTypeEnum = pgEnum("user_type", ["user", "machine"]) - -export const usersTable = pgTable( - "users", - { - id: uuid().primaryKey().defaultRandom().$type(), - externalId: varchar({ length: 255 }).notNull().unique(), - email: varchar({ length: 255 }).notNull(), - firstName: varchar({ length: 100 }).notNull(), - lastName: varchar({ length: 100 }).notNull(), - avatarUrl: text(), - userType: userTypeEnum().notNull().default("user"), - settings: jsonb().$type>(), - isOnboarded: boolean().notNull().default(false), - timezone: varchar({ length: 100 }), // IANA timezone identifier (e.g., "America/New_York") - createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), - deletedAt: timestamp({ mode: "date", withTimezone: true }), - }, - (table) => [ - index("users_external_id_idx").on(table.externalId), - index("users_email_idx").on(table.email), - index("users_user_type_idx").on(table.userType), - index("users_deleted_at_idx").on(table.deletedAt), - ], -) - -// Type exports -export type User = typeof usersTable.$inferSelect -export type NewUser = typeof usersTable.$inferInsert diff --git a/packages/db/src/services/database.test.ts b/packages/db/src/services/database.test.ts index 1114a5b4a..0d8dae9e4 100644 --- a/packages/db/src/services/database.test.ts +++ b/packages/db/src/services/database.test.ts @@ -1,156 +1,185 @@ import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql" -import { Effect, Redacted } from "effect" +import type { ChannelId, OrganizationId } from "@hazel/schema" +import { Channel } from "@hazel/domain/models" +import * as PG from "@maple-dev/effect-orm/postgres" +import { Effect, Layer, Option, Redacted } from "effect" import { afterAll, beforeAll, describe, expect, it } from "vitest" -import { Database, layer, TransactionContext } from "./database" +import { migrateUrl } from "../migrate" +import * as schema from "../tables" +import { Database, layer, sql } from "./database" +import { makeRepository } from "./repository" let container: StartedPostgreSqlContainer +let dbLayer: Layer.Layer beforeAll(async () => { - container = await new PostgreSqlContainer("postgres:alpine").start() - - // Create test table using postgres.js (same driver as the Database service) - const postgres = (await import("postgres")).default - const sql = postgres(container.getConnectionUri()) - await sql`CREATE TABLE IF NOT EXISTS _test_rollback (id TEXT PRIMARY KEY)` - await sql.end() -}, 60000) // 60s timeout for container startup + container = await new PostgreSqlContainer("postgres:17-alpine").start() + await Effect.runPromise(migrateUrl(container.getConnectionUri())) + dbLayer = layer({ url: Redacted.make(container.getConnectionUri()), ssl: false }) + await run( + Effect.gen(function* () { + const db = yield* Database + yield* db.execute(sql`CREATE TABLE _test_rollback (id TEXT PRIMARY KEY)`) + }), + ) +}, 120_000) afterAll(async () => { await container?.stop() }) -describe("Database.transaction", () => { - const getTestLayer = () => - layer({ - url: Redacted.make(container.getConnectionUri()), - ssl: false, - }) +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(dbLayer)) as Effect.Effect) - describe("rollback behavior", () => { - it("should rollback transaction when Effect fails after successful insert", async () => { - const testId = `test-rollback-${Date.now()}` +const countRows = (id: string) => + Effect.gen(function* () { + const db = yield* Database + const rows = yield* db.query(sql`SELECT id FROM _test_rollback WHERE id = ${id}`) + return rows.length + }) - const program = Effect.gen(function* () { - const db = yield* Database +const insertRow = (id: string) => + Effect.gen(function* () { + const db = yield* Database + yield* db.execute(sql`INSERT INTO _test_rollback (id) VALUES (${id})`) + }) - // This transaction should rollback entirely - const result = yield* db +describe("Database.transaction", () => { + it("rolls back when the body fails after a write", async () => { + const id = `rollback-${Date.now()}` + const result = await run( + Effect.gen(function* () { + const db = yield* Database + const outcome = yield* db .transaction( Effect.gen(function* () { - // Get the transaction context to execute within the transaction - const txCtx = yield* TransactionContext - - // Step 1: Insert a record using the transaction client - yield* txCtx.execute((tx: any) => - tx.execute(`INSERT INTO _test_rollback (id) VALUES ('${testId}')`), - ) - - // Step 2: Fail the Effect - return yield* Effect.fail(new Error("Intentional failure")) + yield* insertRow(id) + return yield* Effect.fail("intentional" as const) }), ) .pipe(Effect.result) + return { outcome: outcome._tag, count: yield* countRows(id) } + }), + ) + expect(result).toEqual({ outcome: "Failure", count: 0 }) + }) - // Verify the transaction failed - expect(result._tag).toBe("Failure") - - // Verify the insert was rolled back (query outside transaction) - const rows = yield* db.execute( - (client: any) => - client.$client`SELECT * FROM _test_rollback WHERE id = ${testId}` as Promise, - ) - - // Should be empty because transaction rolled back - expect(rows.length).toBe(0) - }) - - await Effect.runPromise( - program.pipe(Effect.provide(getTestLayer()), Effect.scoped) as Effect.Effect, - ) - }) - - it("should commit transaction when Effect succeeds", async () => { - const testId = `test-commit-${Date.now()}` - - const program = Effect.gen(function* () { + it("commits when the body succeeds", async () => { + const id = `commit-${Date.now()}` + const count = await run( + Effect.gen(function* () { const db = yield* Database + yield* db.transaction(insertRow(id)) + return yield* countRows(id) + }), + ) + expect(count).toBe(1) + }) - // This transaction should commit + it("rolls back only the failed savepoint of a nested transaction", async () => { + const outer = `outer-${Date.now()}` + const inner = `inner-${Date.now()}` + const counts = await run( + Effect.gen(function* () { + const db = yield* Database yield* db.transaction( Effect.gen(function* () { - // Get the transaction context to execute within the transaction - const txCtx = yield* TransactionContext - - yield* txCtx.execute((tx: any) => - tx.execute(`INSERT INTO _test_rollback (id) VALUES ('${testId}')`), - ) - return "success" + yield* insertRow(outer) + yield* db + .transaction( + Effect.gen(function* () { + yield* insertRow(inner) + return yield* Effect.fail("inner" as const) + }), + ) + .pipe(Effect.ignore) }), ) + return [yield* countRows(outer), yield* countRows(inner)] + }), + ) + expect(counts).toEqual([1, 0]) + }) +}) - // Verify the insert persisted (query outside transaction) - const rows = yield* db.execute( - (client: any) => - client.$client`SELECT * FROM _test_rollback WHERE id = ${testId}` as Promise, - ) - - expect(rows.length).toBe(1) +describe("DatabaseError", () => { + it("classifies a unique violation and names the constraint", async () => { + const id = `unique-${Date.now()}` + const error = await run( + Effect.gen(function* () { + yield* insertRow(id) + return yield* Effect.flip(insertRow(id)) + }), + ) + expect(error._tag).toBe("DatabaseError") + expect(error.type).toBe("unique_violation") + expect(error.constraint).toBe("_test_rollback_pkey") + expect(error.sqlState).toBe("23505") + }) +}) - // Cleanup - yield* db.execute( - (client: any) => client.$client`DELETE FROM _test_rollback WHERE id = ${testId}`, +describe("Repository", () => { + it("inserts, reads, updates and deletes through the table definitions", async () => { + const organizationId = crypto.randomUUID() as OrganizationId + const result = await run( + Effect.gen(function* () { + const repo = yield* makeRepository( + schema.channelsTable, + { insert: Channel.Insert, update: Channel.Update }, + { idColumn: "id", name: "Channel" }, ) - }) - - await Effect.runPromise( - program.pipe(Effect.provide(getTestLayer()), Effect.scoped) as Effect.Effect, - ) - }) - - it("should propagate the correct error through Effect when transaction fails", async () => { - const testId = `test-error-${Date.now()}` + const [created] = yield* repo.insert({ + name: "general", + icon: null, + type: "public", + organizationId, + parentChannelId: null, + sectionId: null, + deletedAt: null, + }) + const found = yield* repo.findById(created!.id) + const updated = yield* repo.update({ + id: created!.id, + name: "renamed", + deletedAt: new Date("2026-01-01T00:00:00Z"), + }) + yield* repo.deleteById(created!.id) + const afterDelete = yield* repo.findById(created!.id) + return { created: created!, found, updated, afterDelete } + }), + ) + expect(result.created.name).toBe("general") + expect(result.created.createdAt).toBeInstanceOf(Date) + expect(result.created.organizationId).toBe(organizationId) + expect(Option.getOrThrow(result.found).id).toBe(result.created.id) + expect(result.updated.name).toBe("renamed") + expect(result.updated.deletedAt?.toISOString()).toBe("2026-01-01T00:00:00.000Z") + expect(Option.isNone(result.afterDelete)).toBe(true) + }) - const program = Effect.gen(function* () { + it("reads typed rows with filters, ordering and jsonb", async () => { + const organizationId = crypto.randomUUID() as OrganizationId + const rows = await run( + Effect.gen(function* () { const db = yield* Database - - class CustomTestError { - readonly _tag = "CustomTestError" - constructor(readonly message: string) {} - } - - const result = yield* db - .transaction( - Effect.gen(function* () { - // Get the transaction context to execute within the transaction - const txCtx = yield* TransactionContext - - yield* txCtx.execute((tx: any) => - tx.execute(`INSERT INTO _test_rollback (id) VALUES ('${testId}')`), - ) - return yield* Effect.fail(new CustomTestError("Custom error message")) - }), - ) - .pipe(Effect.result) - - // Verify the transaction failed with the correct error type - expect(result._tag).toBe("Failure") - if (result._tag === "Failure") { - const failure = result.failure - // The error should be our custom error - expect(failure).toBeDefined() - } - - // Verify rollback occurred (query outside transaction) - const rows = yield* db.execute( - (client: any) => - client.$client`SELECT * FROM _test_rollback WHERE id = ${testId}` as Promise, + yield* db.run( + PG.insertInto(schema.channelsTable).values([ + { name: "b", type: "public", organizationId }, + { name: "a", type: "private", organizationId }, + ]), ) - expect(rows.length).toBe(0) - }) - - await Effect.runPromise( - program.pipe(Effect.provide(getTestLayer()), Effect.scoped) as Effect.Effect, - ) - }) + return yield* db.run( + PG.from(schema.channelsTable) + .select("id", "name", "type") + .where(($) => [$.organizationId.eq(organizationId), $.deletedAt.isNull()]) + .orderBy(["name", "asc"]), + ) + }), + ) + expect(rows.map((row) => [row.name, row.type])).toEqual([ + ["a", "private"], + ["b", "public"], + ]) + const _typed: ChannelId = rows[0]!.id }) }) diff --git a/packages/db/src/services/database.ts b/packages/db/src/services/database.ts index 5ddc44a69..933a0f2bf 100644 --- a/packages/db/src/services/database.ts +++ b/packages/db/src/services/database.ts @@ -1,37 +1,22 @@ -import type { ExtractTablesWithRelations } from "drizzle-orm" -import type { PgTransaction } from "drizzle-orm/pg-core" -import { drizzle, type PostgresJsDatabase, type PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js" -import { Schema, Context } from "effect" -import * as Effect from "effect/Effect" -import * as Exit from "effect/Exit" -import * as Layer from "effect/Layer" -import * as Option from "effect/Option" -import * as Redacted from "effect/Redacted" -import * as Schedule from "effect/Schedule" -import postgres from "postgres" -import * as schema from "../schema" - -export type TransactionClient = PgTransaction< - PostgresJsQueryResultHKT, - typeof schema, - ExtractTablesWithRelations -> - -export type Client = PostgresJsDatabase & { - $client: postgres.Sql -} - -export type TxFn = ( - fn: (client: TransactionClient) => Promise, -) => Effect.Effect - -export interface TransactionService { - readonly execute: TxFn -} - -export class TransactionContext extends Context.Service()( - "TransactionContext", -) {} +/** + * The database every Hazel service codes against: effect-orm over `@effect/sql-pg`. + * + * Statements are Effects over the typed tables in `@hazel/db/tables`: + * + * const db = yield* Database.Database + * const rows = yield* db.run(PG.from(schema.channelsTable).select().where(($) => [$.id.eq(id)])) + * + * `db.transaction(effect)` pins every statement in `effect` to one connection through fiber context, + * so repositories and helpers need no transaction handle: whatever runs inside the transaction + * joins it. + */ +import * as PgClient from "@effect/sql-pg/PgClient" +import * as Orm from "@maple-dev/effect-orm/database" +import { CompiledQueryDecodeError, postgresDialect, QueryBuilderError } from "@maple-dev/effect-orm/postgres" +import { Cause, Context, Duration, Effect, Layer, Option, Redacted, Schema, type Scope } from "effect" +import * as Reactivity from "effect/reactivity/Reactivity" +import * as SqlClient from "effect/sql/SqlClient" +import { SqlError } from "effect/sql/SqlError" const DatabaseErrorType = Schema.Literals([ "unique_violation", @@ -40,269 +25,272 @@ const DatabaseErrorType = Schema.Literals([ "query_error", ]) +/** + * A statement or transaction failed. `type` classifies it for callers that branch on it; + * `constraint` names the violated constraint or unique index, when there is one. + */ export class DatabaseError extends Schema.TaggedError()("DatabaseError", { type: DatabaseErrorType, - cause: Schema.Unknown, + message: Schema.String, + sqlState: Schema.optional(Schema.String), + constraint: Schema.optional(Schema.String), + cause: Schema.Defect(), }) { public override toString() { - return `DatabaseError: ${(this.cause as postgres.PostgresError).message}` + return `DatabaseError: ${this.message}` } +} - public override get message() { - return (this.cause as postgres.PostgresError).message +/** Walks a driver error chain (`SqlError` → reason → the server's error) for a string field. */ +const findField = (cause: unknown, field: string): string | undefined => { + let current: unknown = cause + for (let depth = 0; depth < 8 && typeof current === "object" && current !== null; depth++) { + const value = (current as Record)[field] + if (typeof value === "string" && value.length > 0) return value + const next = + "reason" in current && typeof current.reason === "object" + ? current.reason + : "cause" in current + ? current.cause + : undefined + if (next === undefined || next === current) break + current = next } + return undefined +} + +const classify = (sqlState: string | undefined, reason: string): typeof DatabaseErrorType.Type => { + if (sqlState === "23505" || reason === "UniqueViolation") return "unique_violation" + if (sqlState === "23503" || reason === "ForeignKeyViolation") return "foreign_key_violation" + if (sqlState?.startsWith("08") || reason === "ConnectionError") return "connection_error" + return "query_error" } -const matchPgError = (error: unknown) => { - if (error instanceof postgres.PostgresError) { - switch (error.code) { - case "23505": - return new DatabaseError({ type: "unique_violation", cause: error }) - case "23503": - return new DatabaseError({ type: "foreign_key_violation", cause: error }) - case "08000": - return new DatabaseError({ type: "connection_error", cause: error }) - default: - return new DatabaseError({ type: "query_error", cause: error }) +/** What a statement can fail with below this module. */ +type OrmFailure = + | SqlError + | Orm.DatabaseError + | Orm.TransactionCommitFailed + | Orm.TransactionRollbackFailed + | Orm.TransactionOptionsRejected + | Orm.TransactionUnsupported + | Orm.TransactionClosed + | QueryBuilderError + | CompiledQueryDecodeError + +const isOrmFailure = (value: unknown): value is OrmFailure => + value instanceof SqlError || + value instanceof Orm.DatabaseError || + value instanceof Orm.TransactionCommitFailed || + value instanceof Orm.TransactionRollbackFailed || + value instanceof Orm.TransactionOptionsRejected || + value instanceof Orm.TransactionUnsupported || + value instanceof Orm.TransactionClosed || + value instanceof QueryBuilderError || + value instanceof CompiledQueryDecodeError + +export const toDatabaseError = (cause: OrmFailure): DatabaseError => { + const sqlState = + cause instanceof Orm.DatabaseError || cause instanceof Orm.TransactionCommitFailed + ? (cause.sqlState ?? findField(cause.cause, "code")) + : findField(cause, "code") + const reason = + cause instanceof Orm.DatabaseError || cause instanceof Orm.TransactionCommitFailed + ? cause.reason + : cause instanceof SqlError + ? cause.reason._tag + : "Unknown" + const constraint = findField(cause, "constraint") ?? findField(cause, "constraint_name") + return new DatabaseError({ + type: classify(sqlState, reason), + message: cause.message, + ...(sqlState === undefined ? undefined : { sqlState }), + ...(constraint === undefined ? undefined : { constraint }), + cause, + }) +} + +/** + * Absorbs effect-orm's and the driver's failures into `DatabaseError`; anything else the effect + * failed with passes through. A failed COMMIT or ROLLBACK that `@effect/sql` turned into a defect + * comes back as a `DatabaseError` too. + */ +const absorb = ( + self: Effect.Effect, +): Effect.Effect | DatabaseError, R> => + self.pipe( + Effect.catchIf( + (error): error is Extract => isOrmFailure(error), + (error) => Effect.fail(toDatabaseError(error)), + ), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause) && Option.isSome(driverDefect(cause)), + (cause) => Effect.fail(toDatabaseError(Option.getOrThrow(driverDefect(cause)))), + ), + ) as Effect.Effect | DatabaseError, R> + +const driverDefect = (cause: Cause.Cause): Option.Option => { + for (const reason of cause.reasons) { + if ( + Cause.isDieReason(reason) && + isOrmFailure(reason.defect) && + !(reason.defect instanceof Orm.TransactionClosed) + ) { + return Option.some(reason.defect) } } - return null + return Option.none() } -export class DatabaseConnectionLostError extends Schema.TaggedError()( - "DatabaseConnectionLostError", - { - cause: Schema.Unknown, - message: Schema.String, - }, -) {} +/** + * What `run` takes: effect-orm's `Runnable`, plus any builder insert. Under TypeScript 5 a concrete + * `PG.insertInto(table)` is not assignable to `Runnable`'s `CHInsert` (the variance of its + * `onConflictDoUpdate` target), so inserts are recognised by their tag; the row type still comes from + * the query. + */ +export type Runnable = Orm.Runnable | { readonly _tag: "CHInsert" } + +export interface DatabaseShape { + /** Compiles a built query or write, runs it, and decodes its rows (a write's RETURNING rows). */ + readonly run: ( + query: Q, + params?: Given, + ) => Effect.Effect>, DatabaseError> + /** `run`, keeping the first row. */ + readonly first: ( + query: Q, + params?: Given, + ) => Effect.Effect>, DatabaseError> + /** Runs a `sql\`...\`` statement and returns its rows, decoded through `schema` when given. */ + readonly query: { + (statement: Orm.StatementInput): Effect.Effect>, DatabaseError> + ( + statement: Orm.StatementInput, + schema: Orm.RowSchema, + ): Effect.Effect, DatabaseError> + } + /** Runs a statement whose rows are not wanted. */ + readonly execute: (statement: Orm.StatementInput) => Effect.Effect + /** + * Runs `body` in a transaction; a nested call is a savepoint. Any failure, defect or + * interruption rolls back, and the body's own errors come out unchanged. + */ + readonly transaction: ( + body: Effect.Effect, + options?: Orm.TransactionOptions, + ) => Effect.Effect> +} -/** Sentinel error used to trigger Drizzle transaction rollback when an Effect fails */ -class EffectTransactionRollback extends Error { - constructor() { - super("Effect transaction rollback") - this.name = "EffectTransactionRollback" +const makeShape = (getOrm: Effect.Effect): DatabaseShape => { + const run = (query: Runnable, params?: object) => + Effect.flatMap(getOrm, (orm) => absorb(orm.run(query as never, params as never))) + return { + run: run as DatabaseShape["run"], + first: ((query: Runnable, params?: object) => + Effect.map(run(query, params), (rows) => + Option.fromNullishOr(rows[0]), + )) as DatabaseShape["first"], + query: ((statement: Orm.StatementInput, schema?: Orm.RowSchema) => + Effect.flatMap(getOrm, (orm) => + absorb(schema === undefined ? orm.query(statement) : orm.query(statement, schema)), + )) as DatabaseShape["query"], + execute: (statement) => Effect.flatMap(getOrm, (orm) => absorb(orm.execute(statement))), + transaction: (body, options) => + Effect.flatMap(getOrm, (orm) => absorb(orm.transaction(body, options))) as never, } } +export class Database extends Context.Service()("Database") {} + +/** effect-orm's database over a client, for Postgres. */ +export const fromSqlClient = (sql: SqlClient.SqlClient): Orm.DatabaseApi => + Orm.fromSqlClient(sql, { dialect: postgresDialect }) + export type Config = { url: Redacted.Redacted ssl: boolean + maxConnections?: number } +/** A long-lived pool, for long-running processes (Bun servers, the cluster, scripts). */ +export const layer = (config: Config): Layer.Layer => + Layer.effect( + Database, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + const orm = fromSqlClient(sql) + return makeShape(Effect.succeed(orm)) + }), + ).pipe( + Layer.provide( + PgClient.layer({ + url: config.url, + ssl: config.ssl, + connectTimeout: Duration.seconds(10), + ...(config.maxConnections === undefined + ? undefined + : { maxConnections: config.maxConnections }), + }), + ), + ) + /** - * A drizzle client scoped to the current request. On Cloudflare Workers a TCP socket belongs to - * the request that opened it, so an isolate-wide pool cannot be shared across requests; the - * Worker provides one of these per request (see {@link makeRequestConnection}) and - * {@link layerRequestScoped} reads it on every query. + * The database for the current request. On Cloudflare Workers a TCP socket belongs to the request + * that opened it, so an isolate-wide pool cannot be shared across requests; the Worker provides one + * of these per request (see {@link makeRequestConnection}) and {@link layerRequestScoped} reads it + * on every statement. */ -export class DatabaseConnection extends Context.Service()( - "DatabaseConnection", -) {} +export class DatabaseConnection extends Context.Service< + DatabaseConnection, + { readonly orm: Orm.DatabaseApi } +>()("DatabaseConnection") {} /** - * A lazily-connecting client for one request. postgres.js opens no socket until the first - * query, so requests that never touch the database cost nothing. Through Hyperdrive the pool - * lives at the edge, so `max` stays small and type fetching is skipped. + * Opens a client for one request. The pool dials nothing until the first statement, so requests + * that never touch the database cost nothing, and it is closed with the given Scope. Through + * Hyperdrive the pool lives at the edge, so `max` stays small, and statements stay unnamed: a + * named prepared statement belongs to one connection, which the pooler need not hand back. */ export const makeRequestConnection = ( url: string, -): { readonly db: Client; readonly end: () => Promise } => { - const sql = postgres(url, { - max: 5, - fetch_types: false, - idle_timeout: 5, - connect_timeout: 10, - }) - return { db: drizzle(sql, { schema }), end: () => sql.end({ timeout: 5 }) } -} - -const makePooledClient = (config: Config) => +): Effect.Effect => Effect.gen(function* () { - const sql = yield* Effect.acquireRelease( - Effect.sync(() => - postgres(Redacted.value(config.url), { - ssl: config.ssl, - idle_timeout: 0, - connect_timeout: 10, + const services = yield* Layer.build( + PgClient.layerFrom( + PgClient.make({ + url: Redacted.make(url), + maxConnections: 5, + idleTimeout: Duration.seconds(5), + connectTimeout: Duration.seconds(10), + prepare: false, }), - ), - (pool) => Effect.promise(() => pool.end()), - ) - - yield* Effect.tryPromise(() => sql`SELECT 1`).pipe( - Effect.retry( - Schedule.jittered(Schedule.spaced("1.25 seconds")).pipe( - (schedule) => Schedule.max([schedule, Schedule.recurs(10)]), - Schedule.tap(({ attempt }) => - Effect.logWarning( - `[Database client]: Connection to the database failed. Retrying (attempt ${attempt}).`, - ), - ), - ), - ), - Effect.tap(() => Effect.logInfo("[Database client]: Connection to the database established.")), - Effect.orDie, + ).pipe(Layer.provide(Reactivity.layer)), ) - - const db = drizzle(sql, { schema }) - return Effect.succeed(db) + return { orm: fromSqlClient(Context.get(services, SqlClient.SqlClient)) } }) -/** The request's client; a defect when the Worker forgot to provide one. */ -const requestClient: Effect.Effect = Effect.serviceOption(DatabaseConnection).pipe( +/** The request's database; a defect when the Worker forgot to provide one. */ +const requestOrm: Effect.Effect = Effect.serviceOption(DatabaseConnection).pipe( Effect.flatMap((connection) => Option.isSome(connection) - ? Effect.succeed(connection.value.db) + ? Effect.succeed(connection.value.orm) : Effect.die(new Error("DatabaseConnection is not provided for this request")), ), ) -const makeService = (getDb: Effect.Effect) => - Effect.gen(function* () { - const execute = Effect.fn((fn: (client: Client) => Promise) => - Effect.flatMap(getDb, (db) => - Effect.tryPromise({ - try: () => fn(db), - catch: (cause) => { - const error = matchPgError(cause) - if (error !== null) { - return error - } - throw cause - }, - }), - ), - ) - - const transaction = Effect.fn("Database.transaction")((effect: Effect.Effect) => - Effect.all([getDb, Effect.context()]).pipe( - Effect.map(([db, services]) => [db, Effect.runPromiseExitWith(services)] as const), - Effect.flatMap(([db, runPromiseExit]) => - Effect.callback((resume) => { - db.transaction(async (tx: TransactionClient) => { - const txWrapper: TxFn = (fn: (client: TransactionClient) => Promise) => - Effect.tryPromise({ - try: () => fn(tx), - catch: (cause) => { - const error = matchPgError(cause) - if (error !== null) { - return error - } - throw cause - }, - }) - - const withContext = effect.pipe( - Effect.provideService(TransactionContext, { execute: txWrapper }), - ) - - const result = await runPromiseExit(withContext) - if (Exit.isFailure(result)) { - resume(Effect.failCause(result.cause)) - throw new EffectTransactionRollback() - } - resume(Effect.succeed(result.value)) - }).catch((cause) => { - // Ignore our sentinel error - already handled via resume() in onFailure - if (cause instanceof EffectTransactionRollback) return - const error = matchPgError(cause) - resume(error !== null ? Effect.fail(error) : Effect.die(cause)) - }) - }), - ), - ), - ) - - const makeQueryWithSchema = ( - inputSchema: InputSchema, - queryFn: ( - execute: ( - fn: (client: Client | TransactionClient) => Promise, - ) => Effect.Effect, - validatedInput: InputSchema["Type"], - options?: { spanPrefix?: string }, - ) => Effect.Effect, - ) => { - return ( - rawData: unknown, - tx?: ( - fn: (client: TransactionClient) => Promise, - ) => Effect.Effect, - ): Effect.Effect => { - return Effect.gen(function* () { - const validatedInput = yield* Schema.decodeUnknownEffect(inputSchema)(rawData) - - if (tx) { - return yield* queryFn(tx, validatedInput) - } - - const maybeCtx = yield* Effect.serviceOption(TransactionContext) - if (Option.isSome(maybeCtx)) { - return yield* queryFn(maybeCtx.value.execute, validatedInput) - } - - return yield* queryFn(execute, validatedInput) - }).pipe( - Effect.withSpan("queryWithSchema", { - attributes: { "input.schema": inputSchema.ast.toString() }, - }), - ) as Effect.Effect - } - } - - const makeQuery = ( - queryFn: ( - executor: ( - fn: (client: Client | TransactionClient) => Promise, - ) => Effect.Effect, - input: Input, - ) => Effect.Effect, - ) => { - return ( - input: Input, - tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, - ): Effect.Effect => { - return Effect.gen(function* () { - if (tx) { - return yield* queryFn( - tx as ( - fn: (client: Client | TransactionClient) => Promise, - ) => Effect.Effect, - input, - ) - } - - const maybeCtx = yield* Effect.serviceOption(TransactionContext) - if (Option.isSome(maybeCtx)) { - return yield* queryFn( - maybeCtx.value.execute as ( - fn: (client: Client | TransactionClient) => Promise, - ) => Effect.Effect, - input, - ) - } - - return yield* queryFn(execute, input) - }) - } - } - - return { - execute, - transaction, - makeQuery, - makeQueryWithSchema, - } as const - }) - -type Shape = Effect.Success> +/** Reads the per-request {@link DatabaseConnection}; for Cloudflare Workers and Durable Objects. */ +export const layerRequestScoped: Layer.Layer = Layer.succeed(Database, makeShape(requestOrm)) -export class Database extends Context.Service()("Database") {} +/** A database over an existing client (tests over PGlite or a container). */ +export const layerFromSqlClient: Layer.Layer = Layer.effect( + Database, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + return makeShape(Effect.succeed(fromSqlClient(sql))) + }), +) -/** A long-lived pool, for long-running processes (Bun servers, the cluster). */ -export const layer = (config: Config) => - Layer.effect(Database, Effect.flatMap(makePooledClient(config), makeService)) +/** Marks an effect as correct only inside a transaction (see effect-orm's `requireTransaction`). */ +export const requireTransaction = Orm.requireTransaction -/** Reads the per-request {@link DatabaseConnection}; for Cloudflare Workers and Durable Objects. */ -export const layerRequestScoped = Layer.effect(Database, makeService(requestClient)) +export { sql } from "@maple-dev/effect-orm/database" diff --git a/packages/db/src/services/drizzle-effect.ts b/packages/db/src/services/drizzle-effect.ts deleted file mode 100644 index 270be3c1f..000000000 --- a/packages/db/src/services/drizzle-effect.ts +++ /dev/null @@ -1,284 +0,0 @@ -import * as Drizzle from "drizzle-orm" -import * as DrizzleMysql from "drizzle-orm/mysql-core" -import * as DrizzlePg from "drizzle-orm/pg-core" -import * as DrizzleSqlite from "drizzle-orm/sqlite-core" -import { Schema } from "effect" - -type Columns = TTable["_"]["columns"] - -type ColumnSchema = TColumn["dataType"] extends "custom" - ? Schema.Schema - : TColumn["dataType"] extends "json" - ? Schema.Schema - : TColumn extends { enumValues: [string, ...string[]] } - ? Drizzle.Equal extends true - ? Schema.Schema - : Schema.Schema - : TColumn["dataType"] extends "bigint" - ? TColumn extends { mode: "number" } - ? Schema.Schema - : Schema.Schema - : TColumn["dataType"] extends "number" - ? TColumn["columnType"] extends `PgBigInt${number}` - ? Schema.Schema - : Schema.Schema - : TColumn["columnType"] extends "PgNumeric" - ? Schema.Schema - : TColumn["columnType"] extends "PgUUID" - ? Schema.Schema - : TColumn["columnType"] extends "PgDate" - ? TColumn extends { mode: "string" } - ? Schema.Schema - : Schema.Schema - : TColumn["columnType"] extends "PgTimestamp" - ? TColumn extends { mode: "string" } - ? Schema.Schema - : Schema.Schema - : TColumn["dataType"] extends "string" - ? Schema.Schema - : TColumn["dataType"] extends "boolean" - ? Schema.Schema - : TColumn["dataType"] extends "date" - ? TColumn extends { mode: "string" } - ? Schema.Schema - : Schema.Schema - : Schema.Schema - -// Simplified JSON types to prevent inference explosion -type JsonPrimitive = string | number | boolean | null -type JsonObject = { readonly [key: string]: unknown } // Match Schema.Record output -type JsonArray = readonly unknown[] // Match Schema.Array output -type JsonValue = JsonPrimitive | JsonObject | JsonArray - -// Strict JSON types for full validation -type StrictJsonObject = { readonly [key: string]: StrictJsonValue } -type StrictJsonArray = readonly StrictJsonValue[] -type StrictJsonValue = JsonPrimitive | StrictJsonObject | StrictJsonArray - -// Non-recursive JSON schema to avoid type inference explosion -export const JsonValue = Schema.Union([ - Schema.String, - Schema.Number, - Schema.Boolean, - Schema.Null, - Schema.Record(Schema.String, Schema.Unknown), - Schema.Array(Schema.Unknown), -]) satisfies Schema.Schema - -// For cases where you need full JSON validation, use this explicit version -export const StrictJsonValue: Schema.Schema = Schema.suspend(() => - Schema.Union([ - Schema.String, - Schema.Number, - Schema.Boolean, - Schema.Null, - Schema.Record(Schema.String, StrictJsonValue), - Schema.Array(StrictJsonValue), - ]), -) - -// Utility type to prevent unknown keys (similar to drizzle-zod) -type NoUnknownKeys = T & { - [K in Exclude]: never -} - -// Simplified refinement types -type RefineFunction = (schemas: { - [K in keyof Columns]: Schema.Schema -}) => Schema.Schema - -type RefineArg = Schema.Schema | RefineFunction - -// Clean refinement type without ugly satisfies -type TableRefine = { - [K in keyof Columns]?: RefineArg -} - -// Build refine type that maps column schemas to refinement functions -// Properly typed approach that preserves specific column schema types -type BuildRefine> = { - [K in keyof TColumns]?: Schema.Schema | ((schema: ColumnSchema) => Schema.Schema) -} - -// Property signature builders - simplified -// In v4, optional properties are represented via Schema.optional/Schema.optionalKey wrappers -type InsertProperty< - TColumn extends Drizzle.Column, - _TKey extends string, -> = TColumn["_"]["notNull"] extends false - ? Schema.optional>> - : TColumn["_"]["hasDefault"] extends true - ? Schema.optional> - : ColumnSchema - -type SelectProperty = TColumn["_"]["notNull"] extends false - ? Schema.Schema> | null> - : ColumnSchema - -// Base schema builders -type InsertColumnSchemas = { - [K in keyof Columns]: InsertProperty[K], K & string> -} - -type SelectColumnSchemas = { - [K in keyof Columns]: SelectProperty[K]> -} - -// Refined schema builders - controlled complexity -type BuildInsertSchema = Schema.Struct< - InsertColumnSchemas & TRefine -> - -type BuildSelectSchema = Schema.Struct< - SelectColumnSchemas & TRefine -> - -// Clean API functions with type safety -export function createInsertSchema(table: TTable): BuildInsertSchema - -export function createInsertSchema< - TTable extends Drizzle.Table, - TRefine extends BuildRefine>, ->(table: TTable, refine: NoUnknownKeys): BuildInsertSchema - -export function createInsertSchema = {}>( - table: TTable, - refine?: TRefine, -): BuildInsertSchema { - const columns = Drizzle.getTableColumns(table) - const columnEntries = Object.entries(columns) - - let schemaEntries: Record = Object.fromEntries( - columnEntries.map(([name, column]) => [name, mapColumnToSchema(column)]), - ) - - // Apply refinements - if (refine) { - const refinedEntries = Object.entries(refine).map(([name, refineColumn]) => [ - name, - typeof refineColumn === "function" && !Schema.isSchema(refineColumn) - ? (refineColumn as (schema: Schema.Top) => Schema.Top)(schemaEntries[name]!) - : refineColumn, - ]) - - schemaEntries = Object.assign(schemaEntries, Object.fromEntries(refinedEntries)) - } - - // Apply insert-specific optionality rules - for (const [name, column] of columnEntries) { - if (!column.notNull) { - schemaEntries[name] = Schema.optional(Schema.NullOr(schemaEntries[name] as Schema.Top)) - } else if (column.hasDefault) { - schemaEntries[name] = Schema.optional(schemaEntries[name] as Schema.Top) - } - } - - return Schema.Struct(schemaEntries) as unknown as BuildInsertSchema -} - -export function createSelectSchema(table: TTable): BuildSelectSchema - -export function createSelectSchema< - TTable extends Drizzle.Table, - TRefine extends BuildRefine>, ->(table: TTable, refine: NoUnknownKeys): BuildSelectSchema - -export function createSelectSchema = {}>( - table: TTable, - refine?: TRefine, -): BuildSelectSchema { - const columns = Drizzle.getTableColumns(table) - const columnEntries = Object.entries(columns) - - let schemaEntries: Record = Object.fromEntries( - columnEntries.map(([name, column]) => [name, mapColumnToSchema(column)]), - ) - - // Apply refinements first with base schemas - if (refine) { - const refinedEntries = Object.entries(refine).map(([name, refineColumn]) => [ - name, - typeof refineColumn === "function" && !Schema.isSchema(refineColumn) - ? (refineColumn as (schema: Schema.Top) => Schema.Top)(schemaEntries[name]!) - : refineColumn, - ]) - - schemaEntries = Object.assign(schemaEntries, Object.fromEntries(refinedEntries)) - } - - // Apply select-specific nullability rules after refinements - for (const [name, column] of columnEntries) { - if (!column.notNull) { - schemaEntries[name] = Schema.NullOr(schemaEntries[name] as Schema.Top) - } - } - - return Schema.Struct(schemaEntries) as unknown as BuildSelectSchema -} - -// Helper function to check if a column has a mode property -function hasMode(column: any): column is { mode: string } { - return ( - typeof column === "object" && column !== null && "mode" in column && typeof column.mode === "string" - ) -} - -function mapColumnToSchema(column: Drizzle.Column): Schema.Schema { - let type: Schema.Schema | undefined - - if (isWithEnum(column)) { - type = column.enumValues.length > 0 ? Schema.Literals(column.enumValues) : Schema.String - } - - if (!type) { - if (Drizzle.is(column, DrizzlePg.PgUUID)) { - type = Schema.String.check(Schema.isUUID()) - } else if (column.dataType === "custom") { - type = Schema.Any - } else if (column.dataType === "json") { - type = JsonValue - } else if (column.dataType === "array") { - type = Schema.Array(mapColumnToSchema((column as DrizzlePg.PgArray).baseColumn)) - } else if (column.dataType === "number") { - type = Schema.Number - } else if (column.dataType === "bigint") { - // Check if column has mode: "number" - Drizzle converts to JS number at runtime - type = hasMode(column) && column.mode === "number" ? Schema.Number : Schema.BigInt - } else if (column.dataType === "boolean") { - type = Schema.Boolean - } else if (column.dataType === "date") { - type = hasMode(column) && column.mode === "string" ? Schema.String : Schema.Date - } else if (column.dataType === "string") { - // Additional check: if it's a PgTimestamp or PgDate masquerading as string - if (Drizzle.is(column, DrizzlePg.PgTimestamp)) { - type = hasMode(column) && column.mode === "string" ? Schema.String : Schema.Date - } else if (Drizzle.is(column, DrizzlePg.PgDate)) { - type = hasMode(column) && column.mode === "string" ? Schema.String : Schema.Date - } else { - let sType = Schema.String - if ( - (Drizzle.is(column, DrizzlePg.PgChar) || - Drizzle.is(column, DrizzlePg.PgVarchar) || - Drizzle.is(column, DrizzleMysql.MySqlVarChar) || - Drizzle.is(column, DrizzleMysql.MySqlVarBinary) || - Drizzle.is(column, DrizzleMysql.MySqlChar) || - Drizzle.is(column, DrizzleSqlite.SQLiteText)) && - typeof column.length === "number" - ) { - sType = sType.check(Schema.isMaxLength(column.length)) - } - type = sType - } - } - } - - if (!type) { - type = Schema.Any - } - - return type -} - -function isWithEnum(column: Drizzle.Column): column is typeof column & { enumValues: [string, ...string[]] } { - return "enumValues" in column && Array.isArray(column.enumValues) && column.enumValues.length > 0 -} diff --git a/packages/db/src/services/index.ts b/packages/db/src/services/index.ts index cd7026072..7d4a3de88 100644 --- a/packages/db/src/services/index.ts +++ b/packages/db/src/services/index.ts @@ -1,4 +1,3 @@ -export type { DatabaseError, TransactionClient, TxFn } from "./database" +export type { DatabaseError, DatabaseShape } from "./database" export * as Database from "./database" -export * as DrizzleEffect from "./drizzle-effect" export * as Repository from "./repository" diff --git a/packages/db/src/services/repository.ts b/packages/db/src/services/repository.ts index 3bde37283..70140dcf5 100644 --- a/packages/db/src/services/repository.ts +++ b/packages/db/src/services/repository.ts @@ -1,10 +1,9 @@ -import type { InferSelectModel, Table } from "drizzle-orm" -import { eq } from "drizzle-orm" -import { pipe, Struct } from "effect" +import * as PG from "@maple-dev/effect-orm/postgres" +import { DateTime, pipe, Struct } from "effect" import * as Effect from "effect/Effect" import * as Option from "effect/Option" import * as Schema from "effect/Schema" -import { Database, type DatabaseError, type TransactionClient, type TxFn } from "./database" +import { Database, type DatabaseError } from "./database" export interface RepositoryOptions { idColumn: Col @@ -23,6 +22,9 @@ export interface RepositorySchemas + export interface Repository< RecordType, InsertType, @@ -31,50 +33,51 @@ export interface Repository< Name extends string, Id, > { - readonly insert: ( - insert: InsertType, - tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, - ) => Effect.Effect - - readonly insertVoid: ( - insert: InsertType, - tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, - ) => Effect.Effect + readonly insert: (insert: InsertType) => Effect.Effect + + readonly insertVoid: (insert: InsertType) => Effect.Effect readonly update: ( update: PartialExcept, - tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, ) => Effect.Effect readonly updateVoid: ( update: PartialExcept, - tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, ) => Effect.Effect - readonly findById: ( - id: Id, - tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, - ) => Effect.Effect, DatabaseError> + readonly findById: (id: Id) => Effect.Effect, DatabaseError> readonly with: ( id: Id, f: (item: RecordType) => Effect.Effect, ) => Effect.Effect - readonly deleteById: ( - id: Id, - tx?: (fn: (client: TransactionClient) => Promise) => Effect.Effect, - ) => Effect.Effect + readonly deleteById: (id: Id) => Effect.Effect +} + +/** + * The validated domain input as a row of `table`: keys the table has no column for are dropped + * (domain insert schemas carry extras such as a message's `attachmentIds`), `undefined` leaves the + * column to its default, and a `DateTime` becomes the `Date` the timestamp columns take. + */ +export const toRow = (table: AnyTable, input: Record): Record => { + const row: Record = {} + for (const key of Object.keys(table.columns)) { + const value = input[key] + if (value === undefined) continue + row[key] = DateTime.isDateTime(value) ? DateTime.toDate(value) : value + } + return row } export function makeRepository< - T extends Table, + T extends AnyTable, InsertSchema extends Schema.Top, UpdateSchema extends Schema.Struct, - Col extends keyof InferSelectModel & keyof UpdateSchema["Type"] & string, + Col extends keyof PG.SelectRowOf & keyof UpdateSchema["Type"] & string, Name extends string, - RecordType extends InferSelectModel, - Id extends InferSelectModel[Col], + RecordType extends PG.SelectRowOf, + Id extends PG.SelectRowOf[Col], >( table: T, schemas: RepositorySchemas, @@ -88,65 +91,85 @@ export function makeRepository< const db = yield* Database const { idColumn } = options const updateSchema = schemas.update.mapFields(Struct.map(Schema.optional)) + // The id column, typed loosely: the repository is generic over every table. + const byId = ($: any, id: unknown) => [$[idColumn].eq(id)] + const anyTable = table as any + + const decodeInsert = (data: unknown) => + Schema.decodeUnknownEffect(schemas.insert as Schema.Top)(data) as Effect.Effect< + Record, + Schema.SchemaError + > + const decodeUpdate = (data: unknown) => + Schema.decodeUnknownEffect(updateSchema as Schema.Top)(data) as Effect.Effect< + Record, + Schema.SchemaError + > + + const insert = (data: InsertSchema["Type"]) => + decodeInsert(data).pipe( + Effect.flatMap((input) => + db.run(PG.insertInto(anyTable).values(toRow(table, input)).returning() as any), + ), + Effect.withSpan(`${options.name}.insert`), + ) as unknown as Effect.Effect - const insert = (data: InsertSchema["Type"], tx?: TxFn) => - pipe( - db.makeQueryWithSchema(schemas.insert as Schema.Top, (execute, input: any) => - execute((client) => client.insert(table).values([input]).returning()), - )(data, tx), + const insertVoid = (data: InsertSchema["Type"]) => + decodeInsert(data).pipe( + Effect.flatMap((input) => db.run(PG.insertInto(anyTable).values(toRow(table, input)) as any)), + Effect.asVoid, + Effect.withSpan(`${options.name}.insertVoid`), + ) + + const updateRows = (data: PartialExcept) => + decodeUpdate(data).pipe( + Effect.flatMap((input) => { + const { [idColumn]: id, ...rest } = toRow(table, input) + const set = Object.keys(rest).length > 0 ? rest : { [idColumn]: id } + return db.run( + PG.update(anyTable) + .set(set as any) + .where(($: any) => byId($, input[idColumn])) + .returning() as any, + ) + }), ) as unknown as Effect.Effect - const insertVoid = (data: InsertSchema["Type"], tx?: TxFn) => - db.makeQueryWithSchema(schemas.insert as Schema.Top, (execute, input: any) => - execute((client) => client.insert(table).values(input)), - )(data, tx) as unknown as Effect.Effect - - const update = (data: PartialExcept, tx?: TxFn) => - db.makeQueryWithSchema(updateSchema, (execute, input: any) => - execute((client) => - client - .update(table) - .set(input) - // @ts-expect-error drizzle column access is runtime-indexed by configured idColumn - .where(eq(table[idColumn], input[idColumn])) - .returning(), - ).pipe( - Effect.flatMap((result) => - result.length > 0 - ? Effect.succeed(result[0] as RecordType) - : Effect.die(new EntityNotFound({ type: options.name, id: input[idColumn] })), - ), + const update = (data: PartialExcept) => + updateRows(data).pipe( + Effect.flatMap((rows) => + rows.length > 0 + ? Effect.succeed(rows[0] as RecordType) + : Effect.die( + new EntityNotFound({ + type: options.name, + id: (data as Record)[idColumn], + }), + ), ), - )(data, tx) as Effect.Effect - - const updateVoid = (data: PartialExcept, tx?: TxFn) => - db.makeQueryWithSchema(updateSchema, (execute, input: any) => - execute((client) => - client - .update(table) - .set(input) - // @ts-expect-error drizzle column access is runtime-indexed by configured idColumn - .where(eq(table[idColumn], input[idColumn])), - ), - )(data, tx) as unknown as Effect.Effect + Effect.withSpan(`${options.name}.update`), + ) + + const updateVoid = (data: PartialExcept) => + updateRows(data).pipe(Effect.asVoid, Effect.withSpan(`${options.name}.updateVoid`)) - const findById = (id: Id, tx?: TxFn) => - db.makeQuery((execute, inputId: Id) => - execute((client) => - client + const findById = (id: Id) => + db + .first( + PG.from(anyTable) .select() - .from(table as Table) - // @ts-expect-error drizzle column access is runtime-indexed by configured idColumn - .where(eq(table[idColumn], inputId)) - .limit(1), - ).pipe(Effect.map((results) => Option.fromNullishOr(results[0] as RecordType))), - )(id, tx) as Effect.Effect, DatabaseError> - - const deleteById = (id: Id, tx?: TxFn) => - db.makeQuery((execute, inputId: Id) => - // @ts-expect-error drizzle column access is runtime-indexed by configured idColumn - execute((client) => client.delete(table).where(eq(table[idColumn], inputId))), - )(id, tx) as Effect.Effect + .where(($: any) => byId($, id)) + .limit(1) as any, + ) + .pipe(Effect.withSpan(`${options.name}.findById`)) as Effect.Effect< + Option.Option, + DatabaseError + > + + const deleteById = (id: Id) => + db + .run(PG.deleteFrom(anyTable).where(($: any) => byId($, id)) as any) + .pipe(Effect.asVoid, Effect.withSpan(`${options.name}.deleteById`)) const with_ = ( id: Id, diff --git a/packages/db/src/tables/columns.ts b/packages/db/src/tables/columns.ts index a58b818f2..e4168adf1 100644 --- a/packages/db/src/tables/columns.ts +++ b/packages/db/src/tables/columns.ts @@ -43,8 +43,20 @@ export const typed = >(type: T): PG.PgType> => type as unknown as PG.PgType> -/** A Postgres enum type, read as the union of its labels. The `CREATE TYPE` lives in a migration. */ +/** + * A Postgres enum type in `public`, read as the union of its labels. effect-orm does not model enums, + * so the `CREATE TYPE` (and any later `ALTER TYPE`) is written in a `--custom` migration. The name is + * schema-qualified so `effect-orm verify`, which rebuilds the tables in a scratch schema, still + * resolves it. + */ export const pgEnum = ]>( name: string, values: Values, -) => Object.assign(PG.custom(name, Schema.Literals(values)), { enumName: name, enumValues: values }) +) => + Object.assign(PG.custom(`public.${name}`, Schema.Literals(values)), { + enumName: `public.${name}`, + enumValues: values, + }) + +/** `now()`, typed as the `Date` the timestamp columns hold. */ +export const now = () => PG.sql(timestamptz)`now()` diff --git a/packages/db/src/tables/index.ts b/packages/db/src/tables/index.ts index eb0dcad79..09153cd81 100644 --- a/packages/db/src/tables/index.ts +++ b/packages/db/src/tables/index.ts @@ -25,4 +25,4 @@ export * from "./rss-subscriptions" export * from "./typing-indicators" export * from "./user-presence-status" export * from "./users" -export { jsonb, pgEnum, timestamptz, typed, varchar } from "./columns" +export { jsonb, now, pgEnum, timestamptz, typed, varchar } from "./columns" diff --git a/packages/db/src/tables/integration-connections.ts b/packages/db/src/tables/integration-connections.ts index 7d01cf6a8..7b67db2da 100644 --- a/packages/db/src/tables/integration-connections.ts +++ b/packages/db/src/tables/integration-connections.ts @@ -44,7 +44,7 @@ export const integrationConnectionsTable = PG.table("integration_connections", { where: '"deletedAt" IS NULL\n\t\t\t\t\tAND "level" = \'user\'\n\t\t\t\t\tAND "externalAccountId" IS NOT NULL', }, ), - PG.index("int_conn_github_installation_idx", ($) => [PG.jsonText($.metadata, "installationId")]), + PG.index("int_conn_github_installation_idx", ($) => [PG.jsonText($.metadata!, "installationId")]), ], }) export type IntegrationConnection = PG.SelectRowOf diff --git a/packages/db/src/tables/organizations.ts b/packages/db/src/tables/organizations.ts index 140f28e33..0835f6ddb 100644 --- a/packages/db/src/tables/organizations.ts +++ b/packages/db/src/tables/organizations.ts @@ -23,7 +23,7 @@ export const organizationsTable = PG.table("organizations", { PG.index("organizations_deleted_at_idx", ["deletedAt"]), PG.uniqueIndex( "organizations_clerk_org_id_unique", - ($) => [PG.jsonText($.settings, "clerkOrganizationId")], + ($) => [PG.jsonText($.settings!, "clerkOrganizationId")], { where: '"deletedAt" IS NULL AND "settings"->>\'clerkOrganizationId\' IS NOT NULL' }, ), ], diff --git a/packages/domain/package.json b/packages/domain/package.json index aa7b17f1f..fb2ebe54e 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -17,7 +17,6 @@ "dependencies": { "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", - "drizzle-orm": "^0.45.1", "effect": "catalog:effect" }, "devDependencies": { diff --git a/packages/infra/src/cloudflare/hazel-db.ts b/packages/infra/src/cloudflare/hazel-db.ts index 645d2e80f..8c66d6ab2 100644 --- a/packages/infra/src/cloudflare/hazel-db.ts +++ b/packages/infra/src/cloudflare/hazel-db.ts @@ -3,8 +3,8 @@ * and the `HAZEL_DB` Hyperdrive config every Worker binds. PR previews and dev stages point the * Hyperdrive at `HAZEL_PG_URL` (a shared preview database, or docker Postgres under `alchemy dev`). * - * Schema changes stay on `drizzle-kit push` (packages/db has no migration files), so the stack - * declares no migrations. + * Schema changes are effect-orm migrations in `packages/db/migrations`, applied with + * `bun run db:migrate` (packages/db); the stack declares no migrations. */ import * as Cloudflare from "alchemy/Cloudflare" import * as Planetscale from "alchemy/Planetscale" diff --git a/packages/setup/src/commands/setup.ts b/packages/setup/src/commands/setup.ts index 00b3c2266..c8c543e58 100644 --- a/packages/setup/src/commands/setup.ts +++ b/packages/setup/src/commands/setup.ts @@ -567,15 +567,15 @@ export const setupCommand = Command.make( yield* Console.log(pc.dim("\nDry-run complete! No files were written.")) yield* Console.log(`Run without ${pc.cyan("--dry-run")} to apply these changes.\n`) } else { - // Run db:push to initialize database schema + // Run db:migrate to initialize the database schema yield* Console.log( pc.cyan("\n\u2500\u2500\u2500 Step 6: Initialize Database \u2500\u2500\u2500"), ) - yield* Console.log(pc.dim("Running `bun run db:push`...\n")) + yield* Console.log(pc.dim("Running `bun run db:migrate`...\n")) const dbPushResult = yield* Effect.tryPromise({ try: async () => { - const proc = Bun.spawn(["bun", "run", "db:push"], { + const proc = Bun.spawn(["bun", "run", "db:migrate"], { cwd: process.cwd() + "/packages/db", stdout: "inherit", stderr: "inherit", @@ -587,7 +587,7 @@ export const setupCommand = Command.make( }).pipe(Effect.catch(() => Effect.succeed(false))) if (dbPushResult) { - yield* Console.log(pc.green("\n\u2713") + " Database schema pushed") + yield* Console.log(pc.green("\n\u2713") + " Database schema migrated") } else { yield* Console.log( pc.yellow("\n\u26A0") + " Database push failed. You may need to run it manually.", diff --git a/turbo.json b/turbo.json index 3dd1426c4..90f9cbcc6 100644 --- a/turbo.json +++ b/turbo.json @@ -60,7 +60,7 @@ "cache": false, "dependsOn": ["^setup"] }, - "db:push": { + "db:migrate": { "cache": false }, "dev": { From d514870f94c0062d8726a059a3fddee7437b9e53 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 13 Oct 2026 00:40:50 +0200 Subject: [PATCH 3/8] feat(db): DatabaseError carries Postgres's detail; export Transaction Co-Authored-By: Claude Opus 5.5 --- packages/db/src/services/database.test.ts | 1 + packages/db/src/services/database.ts | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/packages/db/src/services/database.test.ts b/packages/db/src/services/database.test.ts index 0d8dae9e4..c74cca080 100644 --- a/packages/db/src/services/database.test.ts +++ b/packages/db/src/services/database.test.ts @@ -115,6 +115,7 @@ describe("DatabaseError", () => { expect(error.type).toBe("unique_violation") expect(error.constraint).toBe("_test_rollback_pkey") expect(error.sqlState).toBe("23505") + expect(error.detail).toBe(`Key (id)=(${id}) already exists.`) }) }) diff --git a/packages/db/src/services/database.ts b/packages/db/src/services/database.ts index 933a0f2bf..06516e80c 100644 --- a/packages/db/src/services/database.ts +++ b/packages/db/src/services/database.ts @@ -34,6 +34,8 @@ export class DatabaseError extends Schema.TaggedError()("Database message: Schema.String, sqlState: Schema.optional(Schema.String), constraint: Schema.optional(Schema.String), + /** Postgres's DETAIL line, e.g. `Key (slug)=(acme) already exists.` */ + detail: Schema.optional(Schema.String), cause: Schema.Defect(), }) { public override toString() { @@ -101,11 +103,13 @@ export const toDatabaseError = (cause: OrmFailure): DatabaseError => { ? cause.reason._tag : "Unknown" const constraint = findField(cause, "constraint") ?? findField(cause, "constraint_name") + const detail = findField(cause, "detail") return new DatabaseError({ type: classify(sqlState, reason), message: cause.message, ...(sqlState === undefined ? undefined : { sqlState }), ...(constraint === undefined ? undefined : { constraint }), + ...(detail === undefined ? undefined : { detail }), cause, }) } @@ -293,4 +297,7 @@ export const layerFromSqlClient: Layer.Layer Date: Tue, 13 Oct 2026 00:42:13 +0200 Subject: [PATCH 4/8] refactor(cluster,electric-proxy): queries on effect-orm Cluster crons and workflows, electric-proxy bot auth and access-context cache query through the PG builder; drizzle-orm leaves both packages. The cluster still probes the database at startup with the old retry schedule, since the new pool dials lazily. Co-Authored-By: Claude Opus 5.5 --- apps/cluster/package.json | 1 - .../cluster/src/cron/presence-cleanup-cron.ts | 27 +-- apps/cluster/src/cron/rss-poll-cron.ts | 51 ++--- .../src/cron/status-expiration-cron.ts | 33 +-- .../src/cron/typing-indicator-cleanup-cron.ts | 13 +- apps/cluster/src/cron/upload-cleanup-cron.ts | 17 +- apps/cluster/src/index.ts | 36 +++- apps/cluster/src/services/bot-user-service.ts | 13 +- .../src/workflows/cleanup-uploads-handler.ts | 37 ++-- .../workflows/github-installation-handler.ts | 52 +++-- .../src/workflows/github-webhook-handler.ts | 36 ++-- .../workflows/message-notification-handler.ts | 188 ++++++------------ .../src/workflows/rss-feed-poll-handler.ts | 43 ++-- .../src/workflows/thread-naming-handler.ts | 85 +++----- apps/electric-proxy/package.json | 1 - apps/electric-proxy/src/auth/bot-auth.ts | 15 +- .../src/cache/access-context-service.ts | 25 +-- 17 files changed, 263 insertions(+), 410 deletions(-) diff --git a/apps/cluster/package.json b/apps/cluster/package.json index 51bbb568c..4fa44faa9 100644 --- a/apps/cluster/package.json +++ b/apps/cluster/package.json @@ -18,7 +18,6 @@ "@hazel/effect-bun": "workspace:*", "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", - "drizzle-orm": "^0.45.1", "effect": "catalog:effect" }, "devDependencies": { diff --git a/apps/cluster/src/cron/presence-cleanup-cron.ts b/apps/cluster/src/cron/presence-cleanup-cron.ts index b82807b84..ba9d92276 100644 --- a/apps/cluster/src/cron/presence-cleanup-cron.ts +++ b/apps/cluster/src/cron/presence-cleanup-cron.ts @@ -1,5 +1,5 @@ import * as ClusterCron from "effect/cluster/ClusterCron" -import { and, Database, inArray, lt, ne, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import * as Cron from "effect/Cron" import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" @@ -23,20 +23,10 @@ export const PresenceCleanupCronLayer = ClusterCron.make({ const timeoutThreshold = new Date(Date.now() - HEARTBEAT_TIMEOUT_MS) // Find users with stale heartbeats who aren't already offline - const staleUsers = yield* db.execute((client) => - client - .select({ - userId: schema.userPresenceStatusTable.userId, - status: schema.userPresenceStatusTable.status, - lastSeenAt: schema.userPresenceStatusTable.lastSeenAt, - }) - .from(schema.userPresenceStatusTable) - .where( - and( - lt(schema.userPresenceStatusTable.lastSeenAt, timeoutThreshold), - ne(schema.userPresenceStatusTable.status, "offline"), - ), - ), + const staleUsers = yield* db.run( + PG.from(schema.userPresenceStatusTable) + .select("userId", "status", "lastSeenAt") + .where(($) => [$.lastSeenAt.lt(timeoutThreshold), $.status.neq("offline")]), ) yield* Effect.annotateCurrentSpan("cron.stale_count", staleUsers.length) @@ -49,14 +39,13 @@ export const PresenceCleanupCronLayer = ClusterCron.make({ // Batch update all stale users to offline const userIds = staleUsers.map((u) => u.userId) - yield* db.execute((client) => - client - .update(schema.userPresenceStatusTable) + yield* db.run( + PG.update(schema.userPresenceStatusTable) .set({ status: "offline", updatedAt: new Date(), }) - .where(inArray(schema.userPresenceStatusTable.userId, userIds)), + .where(($) => [$.userId.in_(...userIds)]), ) yield* Effect.logInfo(`Marked ${staleUsers.length} users as offline`, { diff --git a/apps/cluster/src/cron/rss-poll-cron.ts b/apps/cluster/src/cron/rss-poll-cron.ts index aa1652012..6f6a4ecc5 100644 --- a/apps/cluster/src/cron/rss-poll-cron.ts +++ b/apps/cluster/src/cron/rss-poll-cron.ts @@ -1,6 +1,6 @@ import * as ClusterCron from "effect/cluster/ClusterCron" import { WorkflowEngine } from "effect/workflow" -import { and, Database, eq, isNull, lt, schema, sql } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { Cluster } from "@hazel/domain" import * as Cron from "effect/Cron" import * as DateTime from "effect/DateTime" @@ -28,28 +28,20 @@ export const RssPollCronLayer = ClusterCron.make({ // - enabled and not deleted // - last fetched + polling interval <= now (or never fetched) // - consecutive errors < 10 (circuit breaker) - const dueSubscriptions = yield* db.execute((client) => - client - .select({ - id: schema.rssSubscriptionsTable.id, - channelId: schema.rssSubscriptionsTable.channelId, - organizationId: schema.rssSubscriptionsTable.organizationId, - feedUrl: schema.rssSubscriptionsTable.feedUrl, - pollingIntervalMinutes: schema.rssSubscriptionsTable.pollingIntervalMinutes, - createdAt: schema.rssSubscriptionsTable.createdAt, - }) - .from(schema.rssSubscriptionsTable) - .where( - and( - eq(schema.rssSubscriptionsTable.isEnabled, true), - isNull(schema.rssSubscriptionsTable.deletedAt), - lt(schema.rssSubscriptionsTable.consecutiveErrors, 10), - sql`( - ${schema.rssSubscriptionsTable.lastFetchedAt} IS NULL - OR ${schema.rssSubscriptionsTable.lastFetchedAt} + (${schema.rssSubscriptionsTable.pollingIntervalMinutes} || ' minutes')::interval <= ${nowIso} - )`, - ), - ), + const dueSubscriptions = yield* db.run( + PG.from(schema.rssSubscriptionsTable) + .select("id", "channelId", "organizationId", "feedUrl", "pollingIntervalMinutes", "createdAt") + .where(($) => [ + $.isEnabled.eq(true), + $.deletedAt.isNull(), + $.consecutiveErrors.lt(10), + $.lastFetchedAt + .isNull() + .or( + PG.sql + .cond`${$.lastFetchedAt} + (${$.pollingIntervalMinutes} || ' minutes')::interval <= ${nowIso}`, + ), + ]), ) yield* Effect.annotateCurrentSpan("cron.due_count", dueSubscriptions.length) @@ -90,18 +82,17 @@ export const RssPollCronLayer = ClusterCron.make({ // Increment error counter yield* db - .execute((client) => - client - .update(schema.rssSubscriptionsTable) - .set({ - consecutiveErrors: sql`${schema.rssSubscriptionsTable.consecutiveErrors} + 1`, + .run( + PG.update(schema.rssSubscriptionsTable) + .set(($) => ({ + consecutiveErrors: $.consecutiveErrors.add(1), lastErrorMessage: err instanceof Error ? err.message : String(err), lastErrorAt: new Date(), lastFetchedAt: new Date(), updatedAt: new Date(), - }) - .where(eq(schema.rssSubscriptionsTable.id, sub.id)), + })) + .where(($) => [$.id.eq(sub.id)]), ) .pipe( Effect.catch((dbErr) => diff --git a/apps/cluster/src/cron/status-expiration-cron.ts b/apps/cluster/src/cron/status-expiration-cron.ts index f6dc4076c..bb81afcf9 100644 --- a/apps/cluster/src/cron/status-expiration-cron.ts +++ b/apps/cluster/src/cron/status-expiration-cron.ts @@ -1,5 +1,5 @@ import * as ClusterCron from "effect/cluster/ClusterCron" -import { and, Database, isNotNull, lt, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import * as Cron from "effect/Cron" import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" @@ -20,21 +20,10 @@ export const StatusExpirationCronLayer = ClusterCron.make({ const now = new Date() // Find users with expired statuses - const expiredStatuses = yield* db.execute((client) => - client - .select({ - userId: schema.userPresenceStatusTable.userId, - statusEmoji: schema.userPresenceStatusTable.statusEmoji, - customMessage: schema.userPresenceStatusTable.customMessage, - statusExpiresAt: schema.userPresenceStatusTable.statusExpiresAt, - }) - .from(schema.userPresenceStatusTable) - .where( - and( - isNotNull(schema.userPresenceStatusTable.statusExpiresAt), - lt(schema.userPresenceStatusTable.statusExpiresAt, now), - ), - ), + const expiredStatuses = yield* db.run( + PG.from(schema.userPresenceStatusTable) + .select("userId", "statusEmoji", "customMessage", "statusExpiresAt") + .where(($) => [$.statusExpiresAt.isNotNull(), $.statusExpiresAt.lt(now)]), ) yield* Effect.annotateCurrentSpan("cron.expired_count", expiredStatuses.length) @@ -47,21 +36,15 @@ export const StatusExpirationCronLayer = ClusterCron.make({ // Clear expired statuses by setting emoji, message, and expiration to null const userIds = expiredStatuses.map((s) => s.userId) - yield* db.execute((client) => - client - .update(schema.userPresenceStatusTable) + yield* db.run( + PG.update(schema.userPresenceStatusTable) .set({ statusEmoji: null, customMessage: null, statusExpiresAt: null, updatedAt: new Date(), }) - .where( - and( - isNotNull(schema.userPresenceStatusTable.statusExpiresAt), - lt(schema.userPresenceStatusTable.statusExpiresAt, now), - ), - ), + .where(($) => [$.statusExpiresAt.isNotNull(), $.statusExpiresAt.lt(now)]), ) yield* Effect.logInfo(`Cleared ${expiredStatuses.length} expired statuses`, { diff --git a/apps/cluster/src/cron/typing-indicator-cleanup-cron.ts b/apps/cluster/src/cron/typing-indicator-cleanup-cron.ts index 2ffe943a7..62aa8507e 100644 --- a/apps/cluster/src/cron/typing-indicator-cleanup-cron.ts +++ b/apps/cluster/src/cron/typing-indicator-cleanup-cron.ts @@ -1,5 +1,5 @@ import * as ClusterCron from "effect/cluster/ClusterCron" -import { Database, lt, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import * as Cron from "effect/Cron" import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" @@ -18,13 +18,10 @@ export const TypingIndicatorCleanupCronLayer = ClusterCron.make({ const db = yield* Database.Database const staleThreshold = Date.now() - TYPING_INDICATOR_STALE_MS - const deleted = yield* db.execute((client) => - client - .delete(schema.typingIndicatorsTable) - .where(lt(schema.typingIndicatorsTable.lastTyped, staleThreshold)) - .returning({ - id: schema.typingIndicatorsTable.id, - }), + const deleted = yield* db.run( + PG.deleteFrom(schema.typingIndicatorsTable) + .where(($) => [$.lastTyped.lt(staleThreshold)]) + .returning("id"), ) yield* Effect.annotateCurrentSpan("cron.deleted_count", deleted.length) diff --git a/apps/cluster/src/cron/upload-cleanup-cron.ts b/apps/cluster/src/cron/upload-cleanup-cron.ts index 792bb85e6..de2ee0b6e 100644 --- a/apps/cluster/src/cron/upload-cleanup-cron.ts +++ b/apps/cluster/src/cron/upload-cleanup-cron.ts @@ -1,5 +1,5 @@ import * as ClusterCron from "effect/cluster/ClusterCron" -import { and, Database, eq, isNull, lt, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import * as Cron from "effect/Cron" import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" @@ -24,18 +24,11 @@ export const UploadCleanupCronLayer = ClusterCron.make({ const cutoffTime = new Date(Date.now() - MAX_AGE_MINUTES * 60 * 1000) // Find and mark stale uploads as failed - const result = yield* db.execute((client) => - client - .update(schema.attachmentsTable) + const result = yield* db.run( + PG.update(schema.attachmentsTable) .set({ status: "failed" }) - .where( - and( - eq(schema.attachmentsTable.status, "uploading"), - lt(schema.attachmentsTable.uploadedAt, cutoffTime), - isNull(schema.attachmentsTable.deletedAt), - ), - ) - .returning({ id: schema.attachmentsTable.id }), + .where(($) => [$.status.eq("uploading"), $.uploadedAt.lt(cutoffTime), $.deletedAt.isNull()]) + .returning("id"), ) yield* Effect.annotateCurrentSpan("cron.marked_count", result.length) diff --git a/apps/cluster/src/index.ts b/apps/cluster/src/index.ts index ab0953d4b..524879411 100644 --- a/apps/cluster/src/index.ts +++ b/apps/cluster/src/index.ts @@ -7,7 +7,7 @@ import { WorkflowProxyServer } from "effect/workflow" import { Database } from "@hazel/db" import { Cluster } from "@hazel/domain" import { createTracingLayer } from "@hazel/effect-bun/Telemetry" -import { Config, ConfigProvider, Effect, Layer, Logger, Redacted } from "effect" +import { Config, ConfigProvider, Effect, Layer, Logger, Redacted, Schedule } from "effect" import { PresenceCleanupCronLayer } from "./cron/presence-cleanup-cron.ts" import { StatusExpirationCronLayer } from "./cron/status-expiration-cron.ts" import { TypingIndicatorCleanupCronLayer } from "./cron/typing-indicator-cleanup-cron.ts" @@ -35,11 +35,35 @@ const WorkflowEngineLayer = ClusterWorkflowEngine.layer.pipe( ), ) -// Database layer for Drizzle ORM (uses same credentials as PgClient) -const DatabaseLayer = Database.layer({ - url: Redacted.make(process.env.DATABASE_URL as string)!, - ssl: !process.env.IS_DEV, -}) +// Database layer (uses same credentials as PgClient). The pool connects lazily, so probe it once at +// startup: retry while the database is unreachable, then die, as the previous client did. +const DatabaseLayer = Layer.effectDiscard( + Effect.gen(function* () { + const db = yield* Database.Database + yield* db.execute(Database.sql`SELECT 1`).pipe( + Effect.retry( + Schedule.jittered(Schedule.spaced("1.25 seconds")).pipe( + (schedule) => Schedule.max([schedule, Schedule.recurs(10)]), + Schedule.tap(({ attempt }) => + Effect.logWarning( + `[Database client]: Connection to the database failed. Retrying (attempt ${attempt}).`, + ), + ), + ), + ), + Effect.tap(() => Effect.logInfo("[Database client]: Connection to the database established.")), + Effect.orDie, + ) + }), +).pipe( + Layer.provideMerge( + Database.layer({ + url: Redacted.make(process.env.DATABASE_URL as string)!, + ssl: !process.env.IS_DEV, + }), + ), + Layer.orDie, +) // OpenTelemetry tracing layer const TracerLive = createTracingLayer("cluster") diff --git a/apps/cluster/src/services/bot-user-service.ts b/apps/cluster/src/services/bot-user-service.ts index 6a163eebe..622df184a 100644 --- a/apps/cluster/src/services/bot-user-service.ts +++ b/apps/cluster/src/services/bot-user-service.ts @@ -1,4 +1,4 @@ -import { Database, eq, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { Cluster, Integrations } from "@hazel/domain" import type { UserId } from "@hazel/schema" import { Context, Array, Effect, Layer, Option } from "effect" @@ -40,11 +40,10 @@ export class BotUserService extends Context.Service()("BotUserSe // Query database const results = yield* db - .execute((client) => - client - .select({ id: schema.usersTable.id }) - .from(schema.usersTable) - .where(eq(schema.usersTable.externalId, externalId)) + .run( + PG.from(schema.usersTable) + .select("id") + .where(($) => [$.externalId.eq(externalId)]) .limit(1), ) .pipe( @@ -68,7 +67,7 @@ export class BotUserService extends Context.Service()("BotUserSe return Option.none() } - const userId = headOption.value.id as UserId + const userId = headOption.value.id // Cache the result cache.set(externalId, userId) diff --git a/apps/cluster/src/workflows/cleanup-uploads-handler.ts b/apps/cluster/src/workflows/cleanup-uploads-handler.ts index 3a477a540..8b1d07618 100644 --- a/apps/cluster/src/workflows/cleanup-uploads-handler.ts +++ b/apps/cluster/src/workflows/cleanup-uploads-handler.ts @@ -1,5 +1,5 @@ import { Activity } from "effect/workflow" -import { and, Database, eq, isNull, lt, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { Cluster } from "@hazel/domain" import type { AttachmentId } from "@hazel/schema" import { Effect } from "effect" @@ -32,21 +32,14 @@ export const CleanupUploadsWorkflowLayer = Cluster.CleanupUploadsWorkflow.toLaye const cutoffTime = new Date(Date.now() - maxAgeMinutes * 60 * 1000) const staleUploads = yield* db - .execute((client) => - client - .select({ - id: schema.attachmentsTable.id, - fileName: schema.attachmentsTable.fileName, - uploadedAt: schema.attachmentsTable.uploadedAt, - }) - .from(schema.attachmentsTable) - .where( - and( - eq(schema.attachmentsTable.status, "uploading"), - lt(schema.attachmentsTable.uploadedAt, cutoffTime), - isNull(schema.attachmentsTable.deletedAt), - ), - ), + .run( + PG.from(schema.attachmentsTable) + .select("id", "fileName", "uploadedAt") + .where(($) => [ + $.status.eq("uploading"), + $.uploadedAt.lt(cutoffTime), + $.deletedAt.isNull(), + ]), ) .pipe( Effect.catchTags({ @@ -104,16 +97,10 @@ export const CleanupUploadsWorkflowLayer = Cluster.CleanupUploadsWorkflow.toLaye for (const upload of staleUploadsResult.uploads) { yield* db - .execute((client) => - client - .update(schema.attachmentsTable) + .run( + PG.update(schema.attachmentsTable) .set({ status: "failed" }) - .where( - and( - eq(schema.attachmentsTable.id, upload.id), - eq(schema.attachmentsTable.status, "uploading"), - ), - ), + .where(($) => [$.id.eq(upload.id), $.status.eq("uploading")]), ) .pipe( Effect.catchTags({ diff --git a/apps/cluster/src/workflows/github-installation-handler.ts b/apps/cluster/src/workflows/github-installation-handler.ts index 654c7b7bf..4bab6f720 100644 --- a/apps/cluster/src/workflows/github-installation-handler.ts +++ b/apps/cluster/src/workflows/github-installation-handler.ts @@ -1,5 +1,5 @@ import { Activity } from "effect/workflow" -import { and, Database, eq, isNull, schema, sql } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { Cluster } from "@hazel/domain" import { Effect } from "effect" @@ -39,23 +39,16 @@ export const GitHubInstallationWorkflowLayer = Cluster.GitHubInstallationWorkflo // Query for a connection with matching installationId in metadata const connections = yield* db - .execute((client) => - client - .select({ - id: schema.integrationConnectionsTable.id, - organizationId: schema.integrationConnectionsTable.organizationId, - status: schema.integrationConnectionsTable.status, - externalAccountName: - schema.integrationConnectionsTable.externalAccountName, - }) - .from(schema.integrationConnectionsTable) - .where( - and( - eq(schema.integrationConnectionsTable.provider, "github"), - sql`${schema.integrationConnectionsTable.metadata}->>'installationId' = ${String(payload.installationId)}`, - isNull(schema.integrationConnectionsTable.deletedAt), + .run( + PG.from(schema.integrationConnectionsTable) + .select("id", "organizationId", "status", "externalAccountName") + .where(($) => [ + $.provider.eq("github"), + PG.jsonText($.metadata, "installationId").eq( + String(payload.installationId), ), - ), + $.deletedAt.isNull(), + ]), ) .pipe( Effect.catchTags({ @@ -129,7 +122,11 @@ export const GitHubInstallationWorkflowLayer = Cluster.GitHubInstallationWorkflo ) // For "deleted" action, also set deletedAt - const updateValues = + const updateValues: { + status: "revoked" | "active" | "suspended" + deletedAt?: Date + updatedAt: Date + } = payload.action === "deleted" ? { status: newStatus as "revoked", @@ -142,18 +139,17 @@ export const GitHubInstallationWorkflowLayer = Cluster.GitHubInstallationWorkflo } const updated = yield* db - .execute((client) => - client - .update(schema.integrationConnectionsTable) + .run( + PG.update(schema.integrationConnectionsTable) .set(updateValues) - .where( - and( - eq(schema.integrationConnectionsTable.provider, "github"), - sql`${schema.integrationConnectionsTable.metadata}->>'installationId' = ${String(payload.installationId)}`, - isNull(schema.integrationConnectionsTable.deletedAt), + .where(($) => [ + $.provider.eq("github"), + PG.jsonText($.metadata, "installationId").eq( + String(payload.installationId), ), - ) - .returning({ id: schema.integrationConnectionsTable.id }), + $.deletedAt.isNull(), + ]) + .returning("id"), ) .pipe( Effect.catchTags({ diff --git a/apps/cluster/src/workflows/github-webhook-handler.ts b/apps/cluster/src/workflows/github-webhook-handler.ts index 7b3ab5f8c..f4372dc8f 100644 --- a/apps/cluster/src/workflows/github-webhook-handler.ts +++ b/apps/cluster/src/workflows/github-webhook-handler.ts @@ -1,5 +1,5 @@ import { Activity } from "effect/workflow" -import { and, Database, eq, isNull, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { Cluster } from "@hazel/domain" import type { MessageId } from "@hazel/schema" import { GitHub } from "@hazel/integrations" @@ -38,25 +38,14 @@ export const GitHubWebhookWorkflowLayer = Cluster.GitHubWebhookWorkflow.toLayer( yield* Effect.logDebug(`Querying subscriptions for repository ${payload.repositoryId}`) const subscriptions = yield* db - .execute((client) => - client - .select({ - id: schema.githubSubscriptionsTable.id, - channelId: schema.githubSubscriptionsTable.channelId, - enabledEvents: schema.githubSubscriptionsTable.enabledEvents, - branchFilter: schema.githubSubscriptionsTable.branchFilter, - }) - .from(schema.githubSubscriptionsTable) - .where( - and( - eq( - schema.githubSubscriptionsTable.repositoryId, - payload.repositoryId, - ), - eq(schema.githubSubscriptionsTable.isEnabled, true), - isNull(schema.githubSubscriptionsTable.deletedAt), - ), - ), + .run( + PG.from(schema.githubSubscriptionsTable) + .select("id", "channelId", "enabledEvents", "branchFilter") + .where(($) => [ + $.repositoryId.eq(payload.repositoryId), + $.isEnabled.eq(true), + $.deletedAt.isNull(), + ]), ) .pipe( Effect.catchTags({ @@ -159,9 +148,8 @@ export const GitHubWebhookWorkflowLayer = Cluster.GitHubWebhookWorkflow.toLayer( // Create a message in each eligible channel for (const subscription of eligibleSubscriptions) { const messageResult = yield* db - .execute((client) => - client - .insert(schema.messagesTable) + .run( + PG.insertInto(schema.messagesTable) .values({ channelId: subscription.channelId, authorId: botUserId, @@ -171,7 +159,7 @@ export const GitHubWebhookWorkflowLayer = Cluster.GitHubWebhookWorkflow.toLayer( threadChannelId: null, deletedAt: null, }) - .returning({ id: schema.messagesTable.id }), + .returning("id"), ) .pipe( Effect.catchTags({ diff --git a/apps/cluster/src/workflows/message-notification-handler.ts b/apps/cluster/src/workflows/message-notification-handler.ts index abd4f6905..231ad49cc 100644 --- a/apps/cluster/src/workflows/message-notification-handler.ts +++ b/apps/cluster/src/workflows/message-notification-handler.ts @@ -1,5 +1,5 @@ import { Activity } from "effect/workflow" -import { and, Database, eq, inArray, isNull, ne, or, schema, sql } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { Cluster } from "@hazel/domain" import type { ChannelMemberId, NotificationId, OrganizationMemberId, UserId } from "@hazel/schema" import { Array, Effect, Option, Result } from "effect" @@ -98,52 +98,31 @@ export const MessageNotificationWorkflowLayer = Cluster.MessageNotificationWorkf ) const channelMembers = yield* db - .execute((client) => - client - .select({ - id: schema.channelMembersTable.id, - channelId: schema.channelMembersTable.channelId, - userId: schema.channelMembersTable.userId, - isMuted: schema.channelMembersTable.isMuted, - notificationCount: schema.channelMembersTable.notificationCount, - }) - .from(schema.channelMembersTable) - .leftJoin( - schema.userPresenceStatusTable, - eq( - schema.channelMembersTable.userId, - schema.userPresenceStatusTable.userId, - ), + .run( + PG.from(schema.channelMembersTable) + .leftJoin(schema.userPresenceStatusTable, "presence", (cm, presence) => + presence.userId.eq(cm.userId), ) - .where( - and( - eq(schema.channelMembersTable.channelId, payload.channelId), - eq(schema.channelMembersTable.isMuted, false), - ne(schema.channelMembersTable.userId, payload.authorId), - isNull(schema.channelMembersTable.deletedAt), - or( - // No presence record - send notification - isNull(schema.userPresenceStatusTable.userId), - // Has presence record - check suppressNotifications and activeChannel/status - and( - eq( - schema.userPresenceStatusTable.suppressNotifications, - false, - ), - or( - isNull( - schema.userPresenceStatusTable.activeChannelId, - ), - ne( - schema.userPresenceStatusTable.activeChannelId, - payload.channelId, - ), - ne(schema.userPresenceStatusTable.status, "online"), - ), + .select("id", "channelId", "userId", "isMuted", "notificationCount") + .where(($) => [ + $.channelId.eq(payload.channelId), + $.isMuted.eq(false), + $.userId.neq(payload.authorId), + $.deletedAt.isNull(), + PG.or( + // No presence record - send notification + $.presence.userId.isNull(), + // Has presence record - check suppressNotifications and activeChannel/status + PG.and( + $.presence.suppressNotifications.eq(false), + PG.or( + $.presence.activeChannelId.isNull(), + $.presence.activeChannelId.neq(payload.channelId), + $.presence.status.neq("online"), ), ), ), - ), + ]), ) .pipe( Effect.catchTags({ @@ -180,11 +159,10 @@ export const MessageNotificationWorkflowLayer = Cluster.MessageNotificationWorkf // If this is a reply, get the original message author if (payload.replyToMessageId) { const replyToMessage = yield* db - .execute((client) => - client - .select({ authorId: schema.messagesTable.authorId }) - .from(schema.messagesTable) - .where(eq(schema.messagesTable.id, payload.replyToMessageId!)) + .run( + PG.from(schema.messagesTable) + .select("authorId") + .where(($) => [$.id.eq(payload.replyToMessageId!)]) .limit(1), ) .pipe( @@ -226,50 +204,31 @@ export const MessageNotificationWorkflowLayer = Cluster.MessageNotificationWorkf // Query only the members who should be notified const channelMembers = yield* db - .execute((client) => - client - .select({ - id: schema.channelMembersTable.id, - channelId: schema.channelMembersTable.channelId, - userId: schema.channelMembersTable.userId, - isMuted: schema.channelMembersTable.isMuted, - notificationCount: schema.channelMembersTable.notificationCount, - }) - .from(schema.channelMembersTable) - .leftJoin( - schema.userPresenceStatusTable, - eq( - schema.channelMembersTable.userId, - schema.userPresenceStatusTable.userId, - ), + .run( + PG.from(schema.channelMembersTable) + .leftJoin(schema.userPresenceStatusTable, "presence", (cm, presence) => + presence.userId.eq(cm.userId), ) - .where( - and( - eq(schema.channelMembersTable.channelId, payload.channelId), - inArray(schema.channelMembersTable.userId, uniqueUsersToNotify), - eq(schema.channelMembersTable.isMuted, false), - isNull(schema.channelMembersTable.deletedAt), - or( - // No presence record - send notification - isNull(schema.userPresenceStatusTable.userId), - // Has presence record - check suppressNotifications and activeChannel/status - and( - eq( - schema.userPresenceStatusTable.suppressNotifications, - false, - ), - or( - isNull(schema.userPresenceStatusTable.activeChannelId), - ne( - schema.userPresenceStatusTable.activeChannelId, - payload.channelId, - ), - ne(schema.userPresenceStatusTable.status, "online"), - ), + .select("id", "channelId", "userId", "isMuted", "notificationCount") + .where(($) => [ + $.channelId.eq(payload.channelId), + $.userId.in_(...uniqueUsersToNotify), + $.isMuted.eq(false), + $.deletedAt.isNull(), + PG.or( + // No presence record - send notification + $.presence.userId.isNull(), + // Has presence record - check suppressNotifications and activeChannel/status + PG.and( + $.presence.suppressNotifications.eq(false), + PG.or( + $.presence.activeChannelId.isNull(), + $.presence.activeChannelId.neq(payload.channelId), + $.presence.status.neq("online"), ), ), ), - ), + ]), ) .pipe( Effect.catchTags({ @@ -330,27 +289,17 @@ export const MessageNotificationWorkflowLayer = Cluster.MessageNotificationWorkf (member: Cluster.ChannelMemberForNotification) => member.userId, ) const orgMembers = yield* db - .execute((client) => - client - .select({ - orgMemberId: schema.organizationMembersTable.id, - userId: schema.organizationMembersTable.userId, - }) - .from(schema.organizationMembersTable) - .innerJoin( - schema.channelsTable, - eq( - schema.channelsTable.organizationId, - schema.organizationMembersTable.organizationId, - ), + .run( + PG.from(schema.organizationMembersTable) + .innerJoin(schema.channelsTable, "c", (om, c) => + c.organizationId.eq(om.organizationId), ) - .where( - and( - eq(schema.channelsTable.id, payload.channelId), - inArray(schema.organizationMembersTable.userId, userIds), - isNull(schema.organizationMembersTable.deletedAt), - ), - ), + .select(($) => ({ orgMemberId: $.id, userId: $.userId })) + .where(($) => [ + $.c.id.eq(payload.channelId), + $.userId.in_(...userIds), + $.deletedAt.isNull(), + ]), ) .pipe( Effect.catchTags({ @@ -378,15 +327,11 @@ export const MessageNotificationWorkflowLayer = Cluster.MessageNotificationWorkf } const insertedNotifications = yield* db - .execute((client) => - client - .insert(schema.notificationsTable) + .run( + PG.insertInto(schema.notificationsTable) .values(values) .onConflictDoNothing() - .returning({ - id: schema.notificationsTable.id, - memberId: schema.notificationsTable.memberId, - }), + .returning("id", "memberId"), ) .pipe( Effect.catchTags({ @@ -408,13 +353,12 @@ export const MessageNotificationWorkflowLayer = Cluster.MessageNotificationWorkf if (insertedChannelMemberIds.length > 0) { yield* db - .execute((client) => - client - .update(schema.channelMembersTable) - .set({ - notificationCount: sql`${schema.channelMembersTable.notificationCount} + 1`, - }) - .where(inArray(schema.channelMembersTable.id, insertedChannelMemberIds)), + .run( + PG.update(schema.channelMembersTable) + .set(($) => ({ + notificationCount: $.notificationCount.add(1), + })) + .where(($) => [$.id.in_(...insertedChannelMemberIds)]), ) .pipe( Effect.catchTags({ diff --git a/apps/cluster/src/workflows/rss-feed-poll-handler.ts b/apps/cluster/src/workflows/rss-feed-poll-handler.ts index 866516e3c..77cd79824 100644 --- a/apps/cluster/src/workflows/rss-feed-poll-handler.ts +++ b/apps/cluster/src/workflows/rss-feed-poll-handler.ts @@ -1,5 +1,5 @@ import { Activity } from "effect/workflow" -import { and, Database, eq, isNull, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { Cluster } from "@hazel/domain" import type { MessageId } from "@hazel/schema" import { Rss } from "@hazel/integrations" @@ -77,9 +77,8 @@ export const RssFeedPollWorkflowLayer = Cluster.RssFeedPollWorkflow.toLayer( const db = yield* Database.Database yield* db - .execute((client) => - client - .update(schema.rssSubscriptionsTable) + .run( + PG.update(schema.rssSubscriptionsTable) .set({ lastFetchedAt: new Date(), consecutiveErrors: 0, @@ -91,7 +90,7 @@ export const RssFeedPollWorkflowLayer = Cluster.RssFeedPollWorkflow.toLayer( }), updatedAt: new Date(), }) - .where(eq(schema.rssSubscriptionsTable.id, payload.subscriptionId)), + .where(($) => [$.id.eq(payload.subscriptionId)]), ) .pipe( Effect.catchTag("DatabaseError", (err) => @@ -132,11 +131,10 @@ export const RssFeedPollWorkflowLayer = Cluster.RssFeedPollWorkflow.toLayer( // Get already posted items for deduplication const postedItems = yield* db - .execute((client) => - client - .select({ itemGuid: schema.rssPostedItemsTable.itemGuid }) - .from(schema.rssPostedItemsTable) - .where(eq(schema.rssPostedItemsTable.subscriptionId, payload.subscriptionId)), + .run( + PG.from(schema.rssPostedItemsTable) + .select("itemGuid") + .where(($) => [$.subscriptionId.eq(payload.subscriptionId)]), ) .pipe( Effect.catchTags({ @@ -167,9 +165,8 @@ export const RssFeedPollWorkflowLayer = Cluster.RssFeedPollWorkflow.toLayer( // Still update last fetched time (non-critical, use orDie) yield* db - .execute((client) => - client - .update(schema.rssSubscriptionsTable) + .run( + PG.update(schema.rssSubscriptionsTable) .set({ lastFetchedAt: new Date(), consecutiveErrors: 0, @@ -181,7 +178,7 @@ export const RssFeedPollWorkflowLayer = Cluster.RssFeedPollWorkflow.toLayer( }), updatedAt: new Date(), }) - .where(eq(schema.rssSubscriptionsTable.id, payload.subscriptionId)), + .where(($) => [$.id.eq(payload.subscriptionId)]), ) .pipe(Effect.orDie) @@ -203,9 +200,8 @@ export const RssFeedPollWorkflowLayer = Cluster.RssFeedPollWorkflow.toLayer( const embed = Rss.buildRssEmbed(item, feedResult.feedTitle) const messageResult = yield* db - .execute((client) => - client - .insert(schema.messagesTable) + .run( + PG.insertInto(schema.messagesTable) .values({ channelId: payload.channelId, authorId: botUserId, @@ -215,7 +211,7 @@ export const RssFeedPollWorkflowLayer = Cluster.RssFeedPollWorkflow.toLayer( threadChannelId: null, deletedAt: null, }) - .returning({ id: schema.messagesTable.id }), + .returning("id"), ) .pipe( Effect.catchTags({ @@ -237,8 +233,8 @@ export const RssFeedPollWorkflowLayer = Cluster.RssFeedPollWorkflow.toLayer( // Record posted item for deduplication (non-critical) yield* db - .execute((client) => - client.insert(schema.rssPostedItemsTable).values({ + .run( + PG.insertInto(schema.rssPostedItemsTable).values({ subscriptionId: payload.subscriptionId, itemGuid: item.guid, itemUrl: item.link || null, @@ -261,9 +257,8 @@ export const RssFeedPollWorkflowLayer = Cluster.RssFeedPollWorkflow.toLayer( // Update subscription state (non-critical, use orDie) const lastItem = itemsToPost[0] yield* db - .execute((client) => - client - .update(schema.rssSubscriptionsTable) + .run( + PG.update(schema.rssSubscriptionsTable) .set({ lastFetchedAt: new Date(), lastItemGuid: lastItem?.guid ?? null, @@ -279,7 +274,7 @@ export const RssFeedPollWorkflowLayer = Cluster.RssFeedPollWorkflow.toLayer( }), updatedAt: new Date(), }) - .where(eq(schema.rssSubscriptionsTable.id, payload.subscriptionId)), + .where(($) => [$.id.eq(payload.subscriptionId)]), ) .pipe(Effect.orDie) diff --git a/apps/cluster/src/workflows/thread-naming-handler.ts b/apps/cluster/src/workflows/thread-naming-handler.ts index 743de0a9c..acad72539 100644 --- a/apps/cluster/src/workflows/thread-naming-handler.ts +++ b/apps/cluster/src/workflows/thread-naming-handler.ts @@ -1,6 +1,6 @@ import { AiError, LanguageModel } from "effect/ai" import { Activity } from "effect/workflow" -import { and, Database, eq, isNull, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { Cluster } from "@hazel/domain" import { Effect } from "effect" @@ -39,15 +39,10 @@ export const ThreadNamingWorkflowLayer = Cluster.ThreadNamingWorkflow.toLayer( const db = yield* Database.Database const threadChannel = yield* db - .execute((client) => - client - .select({ - id: schema.channelsTable.id, - name: schema.channelsTable.name, - parentChannelId: schema.channelsTable.parentChannelId, - }) - .from(schema.channelsTable) - .where(eq(schema.channelsTable.id, payload.threadChannelId)) + .run( + PG.from(schema.channelsTable) + .select("id", "name", "parentChannelId") + .where(($) => [$.id.eq(payload.threadChannelId)]) .limit(1), ) .pipe( @@ -74,22 +69,18 @@ export const ThreadNamingWorkflowLayer = Cluster.ThreadNamingWorkflow.toLayer( // Get original message (the one with threadChannelId pointing to this thread) const originalMessage = yield* db - .execute((client) => - client - .select({ - id: schema.messagesTable.id, - content: schema.messagesTable.content, - authorId: schema.messagesTable.authorId, - createdAt: schema.messagesTable.createdAt, - firstName: schema.usersTable.firstName, - lastName: schema.usersTable.lastName, - }) - .from(schema.messagesTable) - .innerJoin( - schema.usersTable, - eq(schema.messagesTable.authorId, schema.usersTable.id), - ) - .where(eq(schema.messagesTable.id, payload.originalMessageId)) + .run( + PG.from(schema.messagesTable, "m") + .innerJoin(schema.usersTable, "u", (m, u) => m.authorId.eq(u.id)) + .select(($) => ({ + id: $.id, + content: $.content, + authorId: $.authorId, + createdAt: $.createdAt, + firstName: $.u.firstName, + lastName: $.u.lastName, + })) + .where(($) => [$.id.eq(payload.originalMessageId)]) .limit(1), ) .pipe( @@ -117,28 +108,19 @@ export const ThreadNamingWorkflowLayer = Cluster.ThreadNamingWorkflow.toLayer( // Get thread messages (messages in the thread channel) const threadMessages = yield* db - .execute((client) => - client - .select({ - id: schema.messagesTable.id, - content: schema.messagesTable.content, - authorId: schema.messagesTable.authorId, - createdAt: schema.messagesTable.createdAt, - firstName: schema.usersTable.firstName, - lastName: schema.usersTable.lastName, - }) - .from(schema.messagesTable) - .innerJoin( - schema.usersTable, - eq(schema.messagesTable.authorId, schema.usersTable.id), - ) - .where( - and( - eq(schema.messagesTable.channelId, payload.threadChannelId), - isNull(schema.messagesTable.deletedAt), - ), - ) - .orderBy(schema.messagesTable.createdAt) + .run( + PG.from(schema.messagesTable, "m") + .innerJoin(schema.usersTable, "u", (m, u) => m.authorId.eq(u.id)) + .select(($) => ({ + id: $.id, + content: $.content, + authorId: $.authorId, + createdAt: $.createdAt, + firstName: $.u.firstName, + lastName: $.u.lastName, + })) + .where(($) => [$.channelId.eq(payload.threadChannelId), $.deletedAt.isNull()]) + .orderBy(($) => [[$.createdAt, "asc"]]) .limit(10), ) .pipe( @@ -288,14 +270,13 @@ export const ThreadNamingWorkflowLayer = Cluster.ThreadNamingWorkflow.toLayer( const db = yield* Database.Database yield* db - .execute((client) => - client - .update(schema.channelsTable) + .run( + PG.update(schema.channelsTable) .set({ name: nameResult.threadName, updatedAt: new Date(), }) - .where(eq(schema.channelsTable.id, payload.threadChannelId)), + .where(($) => [$.id.eq(payload.threadChannelId)]), ) .pipe( Effect.catchTag("DatabaseError", (err) => diff --git a/apps/electric-proxy/package.json b/apps/electric-proxy/package.json index 412d45a05..fd78fa572 100644 --- a/apps/electric-proxy/package.json +++ b/apps/electric-proxy/package.json @@ -19,7 +19,6 @@ "@hazel/infra": "workspace:*", "@hazel/schema": "workspace:*", "alchemy": "catalog:alchemy", - "drizzle-orm": "^0.45.1", "effect": "catalog:effect" }, "devDependencies": { diff --git a/apps/electric-proxy/src/auth/bot-auth.ts b/apps/electric-proxy/src/auth/bot-auth.ts index 7b136c5a8..83474e27a 100644 --- a/apps/electric-proxy/src/auth/bot-auth.ts +++ b/apps/electric-proxy/src/auth/bot-auth.ts @@ -1,4 +1,4 @@ -import { and, Database, eq, isNull, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import type { BotId, UserId } from "@hazel/schema" import { Effect, Option, Schema } from "effect" import { AccessContextCacheService, type BotAccessContext } from "../cache" @@ -68,17 +68,12 @@ export const validateBotToken = Effect.fn("ElectricProxy.validateBotToken")(func // Query bot by token hash const db = yield* Database.Database - const botResult = yield* db.execute((client) => - client - .select({ - id: schema.botsTable.id, - userId: schema.botsTable.userId, - }) - .from(schema.botsTable) - .where(and(eq(schema.botsTable.apiTokenHash, tokenHash), isNull(schema.botsTable.deletedAt))) + const botOption = yield* db.first( + PG.from(schema.botsTable) + .select("id", "userId") + .where(($) => [$.apiTokenHash.eq(tokenHash), $.deletedAt.isNull()]) .limit(1), ) - const botOption = Option.fromNullishOr(botResult[0]) if (Option.isNone(botOption)) { yield* Effect.annotateCurrentSpan("auth.token.valid", false) diff --git a/apps/electric-proxy/src/cache/access-context-service.ts b/apps/electric-proxy/src/cache/access-context-service.ts index 499159bbc..76b65d3ca 100644 --- a/apps/electric-proxy/src/cache/access-context-service.ts +++ b/apps/electric-proxy/src/cache/access-context-service.ts @@ -1,5 +1,5 @@ import { PersistedCache, Persistence } from "effect/persistence" -import { and, Database, eq, isNull, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import type { BotId, ChannelId, UserId } from "@hazel/schema" import { Context, Effect, Layer, Schema } from "effect" import { @@ -54,21 +54,14 @@ export class AccessContextCacheService extends Context.Service - client - .selectDistinct({ channelId: schema.channelsTable.id }) - .from(schema.botInstallationsTable) - .innerJoin( - schema.channelsTable, - and( - eq( - schema.channelsTable.organizationId, - schema.botInstallationsTable.organizationId, - ), - isNull(schema.channelsTable.deletedAt), - ), + .run( + PG.from(schema.botInstallationsTable, "bi") + .innerJoin(schema.channelsTable, "c", (bi, c) => + c.organizationId.eq(bi.organizationId).and(c.deletedAt.isNull()), ) - .where(eq(schema.botInstallationsTable.botId, botId)), + .select(($) => ({ channelId: $.c.id })) + .distinct() + .where(($) => [$.botId.eq(botId)]), ) .pipe( Effect.catchTag("DatabaseError", (error) => @@ -83,7 +76,7 @@ export class AccessContextCacheService extends Context.Service c.channelId) + const channelIds = channels.map((c) => c.channelId) yield* Effect.annotateCurrentSpan("cache.result_size", channelIds.length) return { channelIds } From afeb0b4179fcfd62aa15783aa3b6bb6da8a069f0 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 13 Oct 2026 00:42:23 +0200 Subject: [PATCH 5/8] refactor(backend): RPC handlers, routes and scripts on effect-orm Handlers query through db.run over the PG builder instead of transactionAwareExecute and drizzle; unique-violation checks read DatabaseError.constraint and the slug conflict reads its detail. Maintenance scripts and the setup bots command follow, and drizzle-orm and drizzle-kit leave apps/backend. Co-Authored-By: Claude Opus 5.5 --- apps/backend/package.json | 2 - apps/backend/scripts/create-bot.ts | 18 +-- .../backend/scripts/rebuild-channel-access.ts | 28 ++--- apps/backend/scripts/reset-all.ts | 11 +- apps/backend/scripts/seed-internal-bots.ts | 29 ++--- .../src/routes/api-v1/integrations.http.ts | 71 ++++------- .../src/routes/http-success-encoding.test.ts | 12 +- apps/backend/src/rpc/handlers/bots.ts | 93 ++++++-------- .../src/rpc/handlers/channel-sections.ts | 56 ++++----- apps/backend/src/rpc/handlers/channels.ts | 31 +++-- .../src/rpc/handlers/connect-shares.test.ts | 5 +- .../src/rpc/handlers/connect-shares.ts | 37 ++---- .../src/rpc/handlers/integration-requests.ts | 8 +- .../backend/src/rpc/handlers/organizations.ts | 9 +- .../migrate-to-clerk/backfill-external-ids.ts | 61 ++++----- .../diagnose-org-duplicates.ts | 71 ++++------- .../migrate-to-clerk/merge-org-duplicates.ts | 119 ++++++------------ packages/setup/src/commands/bots.ts | 30 ++--- 18 files changed, 266 insertions(+), 425 deletions(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index 36f2332f8..85d93eacb 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -41,7 +41,6 @@ "alchemy": "catalog:alchemy", "aws4fetch": "^1.0.20", "dfx": "^1.1.0", - "drizzle-orm": "^0.45.1", "effect": "catalog:effect", "jose": "^6.1.3", "pg": "^8.16.3", @@ -51,7 +50,6 @@ "@cloudflare/workers-types": "catalog:alchemy", "@testcontainers/postgresql": "^10.18.0", "@types/bun": "1.3.9", - "drizzle-kit": "^0.31.8", "typescript": "^5.9.3" } } diff --git a/apps/backend/scripts/create-bot.ts b/apps/backend/scripts/create-bot.ts index a9d824ff0..9c06b1ac5 100644 --- a/apps/backend/scripts/create-bot.ts +++ b/apps/backend/scripts/create-bot.ts @@ -13,7 +13,7 @@ * 4. Print the bot token (save it - you can't retrieve it later!) */ -import { Database, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import type { BotId, BotInstallationId, OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" import { Effect, References } from "effect" import { randomUUID } from "crypto" @@ -67,8 +67,8 @@ const createBotScript = Effect.gen(function* () { // 1. Create a user for the bot (userType = 'machine') console.log(" → Creating bot user...") - yield* db.execute((client) => - client.insert(schema.usersTable).values({ + yield* db.run( + PG.insertInto(schema.usersTable).values({ id: botUserId, externalId, email: botEmail, @@ -82,8 +82,8 @@ const createBotScript = Effect.gen(function* () { // 2. Create the bot console.log(" → Creating bot record...") - yield* db.execute((client) => - client.insert(schema.botsTable).values({ + yield* db.run( + PG.insertInto(schema.botsTable).values({ id: botId, userId: botUserId, createdBy: botUserId, @@ -97,8 +97,8 @@ const createBotScript = Effect.gen(function* () { // 3. Install the bot in the organization console.log(" → Installing bot in organization...") - yield* db.execute((client) => - client.insert(schema.botInstallationsTable).values({ + yield* db.run( + PG.insertInto(schema.botInstallationsTable).values({ id: installationId, botId, organizationId: orgId, @@ -109,8 +109,8 @@ const createBotScript = Effect.gen(function* () { // 4. Add bot user to the organization as a member console.log(" → Adding bot user to organization...") - yield* db.execute((client) => - client.insert(schema.organizationMembersTable).values({ + yield* db.run( + PG.insertInto(schema.organizationMembersTable).values({ id: membershipId, organizationId: orgId, userId: botUserId, diff --git a/apps/backend/scripts/rebuild-channel-access.ts b/apps/backend/scripts/rebuild-channel-access.ts index 39a49092f..ecaa8c111 100644 --- a/apps/backend/scripts/rebuild-channel-access.ts +++ b/apps/backend/scripts/rebuild-channel-access.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import { and, Database, eq, isNull, ne, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { Effect, Layer, References } from "effect" import { ChannelAccessSyncService } from "../src/services/channel-access-sync" import { DatabaseLive } from "../src/services/database" @@ -10,13 +10,12 @@ const rebuildChannelAccess = Effect.gen(function* () { yield* Effect.logInfo("Rebuilding channel_access table") - yield* db.execute((client) => client.delete(schema.channelAccessTable)) + yield* db.run(PG.deleteFrom(schema.channelAccessTable).allRows()) - const activeNonThreadChannels = yield* db.execute((client) => - client - .select({ id: schema.channelsTable.id }) - .from(schema.channelsTable) - .where(and(isNull(schema.channelsTable.deletedAt), ne(schema.channelsTable.type, "thread"))), + const activeNonThreadChannels = yield* db.run( + PG.from(schema.channelsTable) + .select("id") + .where(($) => [$.deletedAt.isNull(), $.type.neq("thread")]), ) yield* Effect.forEach( @@ -25,11 +24,10 @@ const rebuildChannelAccess = Effect.gen(function* () { { concurrency: 20 }, ) - const threadChannels = yield* db.execute((client) => - client - .select({ id: schema.channelsTable.id }) - .from(schema.channelsTable) - .where(and(isNull(schema.channelsTable.deletedAt), eq(schema.channelsTable.type, "thread"))), + const threadChannels = yield* db.run( + PG.from(schema.channelsTable) + .select("id") + .where(($) => [$.deletedAt.isNull(), $.type.eq("thread")]), ) yield* Effect.forEach( @@ -40,10 +38,10 @@ const rebuildChannelAccess = Effect.gen(function* () { }, ) - const countResult = yield* db.execute( - (client) => client.$client`SELECT COUNT(*)::int AS count FROM channel_access`, + const countResult = yield* db.run( + PG.from(schema.channelAccessTable).select(() => ({ count: PG.count() })), ) - const count = (countResult[0] as { count: number } | undefined)?.count ?? 0 + const count = countResult[0]?.count ?? 0 console.log( `channel_access rebuilt: ${activeNonThreadChannels.length} non-thread channels, ${threadChannels.length} threads, ${count} rows`, diff --git a/apps/backend/scripts/reset-all.ts b/apps/backend/scripts/reset-all.ts index af7d04400..bb161e1ca 100644 --- a/apps/backend/scripts/reset-all.ts +++ b/apps/backend/scripts/reset-all.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun import { Database } from "@hazel/db" -import { Effect, References } from "effect" +import { Effect, References, Schema } from "effect" import { DatabaseLive } from "../src/services/database" // Parse command line arguments @@ -54,9 +54,12 @@ const clearDatabase = Effect.gen(function* () { for (const table of allTables) { const countResult = yield* db - .execute((client) => client.$client`SELECT COUNT(*)::int as count FROM ${client.$client(table)}`) + .query( + Database.sql`SELECT COUNT(*)::int as count FROM ${Database.sql.identifier(table)}`, + Schema.Struct({ count: Schema.Number }), + ) .pipe(Effect.orDie) - const count = (countResult[0] as { count: number } | undefined)?.count ?? 0 + const count = countResult[0]?.count ?? 0 tableCounts[table] = count if (count === 0) { @@ -68,7 +71,7 @@ const clearDatabase = Effect.gen(function* () { log("yellow", ` [DRY RUN] Would delete ${count} rows from ${table}`) } else { yield* db - .execute((client) => client.$client`TRUNCATE TABLE ${client.$client(table)} CASCADE`) + .execute(Database.sql`TRUNCATE TABLE ${Database.sql.identifier(table)} CASCADE`) .pipe(Effect.orDie) log("green", ` ✓ Cleared ${count} rows from ${table}`) } diff --git a/apps/backend/scripts/seed-internal-bots.ts b/apps/backend/scripts/seed-internal-bots.ts index 8e4887041..0ea454a5d 100644 --- a/apps/backend/scripts/seed-internal-bots.ts +++ b/apps/backend/scripts/seed-internal-bots.ts @@ -11,10 +11,9 @@ * If a bot already exists, it will be skipped and no new token will be generated. */ -import { Database, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import type { BotId, UserId } from "@hazel/schema" import { createHash, randomUUID } from "crypto" -import { eq } from "drizzle-orm" import { Effect, References } from "effect" import { DatabaseLive } from "../src/services/database" @@ -96,25 +95,27 @@ const seedBot = Effect.fn("seedInternalBots.seedBot")(function* (provider: Inter console.log(" -> Checking if bot exists...") // Check if bot user already exists - const existingUser = yield* db.execute((client) => - client.select().from(schema.usersTable).where(eq(schema.usersTable.externalId, externalId)).limit(1), + const existingUser = yield* db.run( + PG.from(schema.usersTable) + .select() + .where(($) => [$.externalId.eq(externalId)]) + .limit(1), ) if (existingUser.length > 0) { console.log(" -> Bot already exists, skipping...") - console.log(` Bot User ID: ${existingUser[0].id}`) + console.log(` Bot User ID: ${existingUser[0]!.id}`) // Get the bot record to show bot ID - const existingBot = yield* db.execute((client) => - client + const existingBot = yield* db.run( + PG.from(schema.botsTable) .select() - .from(schema.botsTable) - .where(eq(schema.botsTable.userId, existingUser[0].id as UserId)) + .where(($) => [$.userId.eq(existingUser[0]!.id)]) .limit(1), ) if (existingBot.length > 0) { - console.log(` Bot ID: ${existingBot[0].id}`) + console.log(` Bot ID: ${existingBot[0]!.id}`) } return { created: false, provider } @@ -128,8 +129,8 @@ const seedBot = Effect.fn("seedInternalBots.seedBot")(function* (provider: Inter // Create bot user console.log(" -> Creating bot user...") - yield* db.execute((client) => - client.insert(schema.usersTable).values({ + yield* db.run( + PG.insertInto(schema.usersTable).values({ id: botUserId, externalId, email: botEmail, @@ -143,8 +144,8 @@ const seedBot = Effect.fn("seedInternalBots.seedBot")(function* (provider: Inter // Create bot record console.log(" -> Creating bot record...") - yield* db.execute((client) => - client.insert(schema.botsTable).values({ + yield* db.run( + PG.insertInto(schema.botsTable).values({ id: botId, userId: botUserId, createdBy: botUserId, diff --git a/apps/backend/src/routes/api-v1/integrations.http.ts b/apps/backend/src/routes/api-v1/integrations.http.ts index 9ce68a612..4cfcdc518 100644 --- a/apps/backend/src/routes/api-v1/integrations.http.ts +++ b/apps/backend/src/routes/api-v1/integrations.http.ts @@ -2,7 +2,7 @@ import { createHash, randomBytes } from "node:crypto" import { HttpApiBuilder } from "effect/http-api" import { HttpServerRequest } from "effect/http" import { ChannelRepo, ChannelWebhookRepo, OrganizationMemberRepo } from "@hazel/backend-core" -import { and, Database, eq, isNull, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { CurrentUser, InternalServerError, UnauthorizedError } from "@hazel/domain" import { ApiV1ChannelNotFoundError, @@ -71,32 +71,23 @@ export const HttpApiV1IntegrationsLive = HttpApiBuilder.group(HazelApi, "api-v1- const { currentUser } = yield* authenticate const rows = yield* db - .makeQuery((execute, userId: typeof currentUser.id) => - execute((client) => - client - .select({ - id: schema.organizationsTable.id, - name: schema.organizationsTable.name, - slug: schema.organizationsTable.slug, - logoUrl: schema.organizationsTable.logoUrl, - }) - .from(schema.organizationMembersTable) - .innerJoin( - schema.organizationsTable, - eq( - schema.organizationsTable.id, - schema.organizationMembersTable.organizationId, - ), - ) - .where( - and( - eq(schema.organizationMembersTable.userId, userId), - isNull(schema.organizationMembersTable.deletedAt), - isNull(schema.organizationsTable.deletedAt), - ), - ), - ), - )(currentUser.id) + .run( + PG.from(schema.organizationMembersTable) + .innerJoin(schema.organizationsTable, "o", (om, o) => + o.id.eq(om.organizationId), + ) + .select(($) => ({ + id: $.o.id, + name: $.o.name, + slug: $.o.slug, + logoUrl: $.o.logoUrl, + })) + .where(($) => [ + $.userId.eq(currentUser.id), + $.deletedAt.isNull(), + $.o.deletedAt.isNull(), + ]), + ) .pipe( Effect.catchTag("DatabaseError", (err) => Effect.fail( @@ -145,24 +136,14 @@ export const HttpApiV1IntegrationsLive = HttpApiBuilder.group(HazelApi, "api-v1- } const rows = yield* db - .makeQuery((execute, orgId: typeof params.organizationId) => - execute((client) => - client - .select({ - id: schema.channelsTable.id, - name: schema.channelsTable.name, - type: schema.channelsTable.type, - organizationId: schema.channelsTable.organizationId, - }) - .from(schema.channelsTable) - .where( - and( - eq(schema.channelsTable.organizationId, orgId), - isNull(schema.channelsTable.deletedAt), - ), - ), - ), - )(params.organizationId) + .run( + PG.from(schema.channelsTable) + .select("id", "name", "type", "organizationId") + .where(($) => [ + $.organizationId.eq(params.organizationId), + $.deletedAt.isNull(), + ]), + ) .pipe( Effect.catchTag("DatabaseError", (err) => Effect.fail( diff --git a/apps/backend/src/routes/http-success-encoding.test.ts b/apps/backend/src/routes/http-success-encoding.test.ts index ddd2143fc..e11d16c50 100644 --- a/apps/backend/src/routes/http-success-encoding.test.ts +++ b/apps/backend/src/routes/http-success-encoding.test.ts @@ -71,14 +71,12 @@ const makeDatabaseLayer = () => Database.Database, serviceShape({ transaction: (effect: Effect.Effect) => - Effect.provideService(effect, Database.TransactionContext, { - execute: (fn) => - Effect.promise(() => - fn({ - execute: async () => [{ txid: "42" }], - } as never), - ), + Effect.provideService(effect, Database.Transaction, { + depth: 0, + isolationLevel: undefined, + accessMode: undefined, }), + query: () => Effect.succeed([{ txid: "42" }]), }), ) diff --git a/apps/backend/src/rpc/handlers/bots.ts b/apps/backend/src/rpc/handlers/bots.ts index a6d32756b..5770ccab4 100644 --- a/apps/backend/src/rpc/handlers/bots.ts +++ b/apps/backend/src/rpc/handlers/bots.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from "node:crypto" import { BotCommandRepo, BotInstallationRepo, BotRepo, UserRepo } from "@hazel/backend-core" -import { and, Database, eq, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { CurrentUser, withRemapDbErrors } from "@hazel/domain" import type { BotId, BotInstallationId, OrganizationMemberId, UserId } from "@hazel/schema" import { @@ -15,7 +15,6 @@ import { } from "@hazel/domain/rpc" import { Effect, Option } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" -import { transactionAwareExecute } from "../../lib/transaction-aware-execute" import { BotPolicy } from "../../policies/bot-policy" import { checkBotOperationRateLimit, checkBotUpdateRateLimit } from "../../services/rate-limit-helpers" import { ChannelAccessSyncService } from "../../services/channel-access-sync" @@ -75,8 +74,8 @@ export const BotRpcLive = BotRpcs.toLayer( const externalId = `bot_${botUserId}` // 1. Create a machine user for the bot - yield* transactionAwareExecute((client) => - client.insert(schema.usersTable).values({ + yield* db.run( + PG.insertInto(schema.usersTable).values({ id: botUserId, externalId, email: botEmail, @@ -88,9 +87,8 @@ export const BotRpcLive = BotRpcs.toLayer( ) // 2. Create the bot record - const [bot] = yield* transactionAwareExecute((client) => - client - .insert(schema.botsTable) + const [bot] = yield* db.run( + PG.insertInto(schema.botsTable) .values({ id: botId, userId: botUserId, @@ -109,8 +107,8 @@ export const BotRpcLive = BotRpcs.toLayer( ) // 3. Install the bot in the creator's organization - yield* transactionAwareExecute((client) => - client.insert(schema.botInstallationsTable).values({ + yield* db.run( + PG.insertInto(schema.botInstallationsTable).values({ id: installationId, botId, organizationId, @@ -119,8 +117,8 @@ export const BotRpcLive = BotRpcs.toLayer( ) // 4. Add bot user to the organization as a member - yield* transactionAwareExecute((client) => - client.insert(schema.organizationMembersTable).values({ + yield* db.run( + PG.insertInto(schema.organizationMembersTable).values({ id: membershipId, organizationId, userId: botUserId, @@ -203,11 +201,10 @@ export const BotRpcLive = BotRpcs.toLayer( // Keep machine user's firstName in sync with bot name if (payload.name) { const bot = botOption.value - yield* transactionAwareExecute((client) => - client - .update(schema.usersTable) + yield* db.run( + PG.update(schema.usersTable) .set({ firstName: payload.name, updatedAt: new Date() }) - .where(eq(schema.usersTable.id, bot.userId)), + .where(($) => [$.id.eq(bot.userId)]), ) } @@ -416,8 +413,8 @@ export const BotRpcLive = BotRpcs.toLayer( // Create installation const installationId = randomUUID() as BotInstallationId - yield* transactionAwareExecute((client) => - client.insert(schema.botInstallationsTable).values({ + yield* db.run( + PG.insertInto(schema.botInstallationsTable).values({ id: installationId, botId, organizationId, @@ -427,9 +424,8 @@ export const BotRpcLive = BotRpcs.toLayer( // Add bot user to the organization as a member (reactivate if soft-deleted) const membershipId = randomUUID() as OrganizationMemberId - yield* transactionAwareExecute((client) => - client - .insert(schema.organizationMembersTable) + yield* db.run( + PG.insertInto(schema.organizationMembersTable) .values({ id: membershipId, organizationId, @@ -437,10 +433,7 @@ export const BotRpcLive = BotRpcs.toLayer( role: "member", }) .onConflictDoUpdate({ - target: [ - schema.organizationMembersTable.organizationId, - schema.organizationMembersTable.userId, - ], + target: ["organizationId", "userId"], set: { deletedAt: null }, }), ) @@ -489,34 +482,23 @@ export const BotRpcLive = BotRpcs.toLayer( } // Delete the installation - yield* transactionAwareExecute((client) => - client - .delete(schema.botInstallationsTable) - .where( - eq(schema.botInstallationsTable.id, installationOption.value.id), - ), + yield* db.run( + PG.deleteFrom(schema.botInstallationsTable).where(($) => [ + $.id.eq(installationOption.value.id), + ]), ) // Get bot to remove bot user from org const botOption = yield* botRepo.findById(botId) if (Option.isSome(botOption)) { // Remove bot user from organization (soft delete) - yield* transactionAwareExecute((client) => - client - .update(schema.organizationMembersTable) + yield* db.run( + PG.update(schema.organizationMembersTable) .set({ deletedAt: new Date() }) - .where( - and( - eq( - schema.organizationMembersTable.organizationId, - organizationId, - ), - eq( - schema.organizationMembersTable.userId, - botOption.value.userId, - ), - ), - ), + .where(($) => [ + $.organizationId.eq(organizationId), + $.userId.eq(botOption.value.userId), + ]), ) yield* channelAccessSync.syncUserInOrganization( @@ -573,8 +555,8 @@ export const BotRpcLive = BotRpcs.toLayer( // Create installation const installationId = randomUUID() as BotInstallationId - yield* transactionAwareExecute((client) => - client.insert(schema.botInstallationsTable).values({ + yield* db.run( + PG.insertInto(schema.botInstallationsTable).values({ id: installationId, botId, organizationId, @@ -584,9 +566,8 @@ export const BotRpcLive = BotRpcs.toLayer( // Add bot user to the organization as a member (reactivate if soft-deleted) const membershipId = randomUUID() as OrganizationMemberId - yield* transactionAwareExecute((client) => - client - .insert(schema.organizationMembersTable) + yield* db.run( + PG.insertInto(schema.organizationMembersTable) .values({ id: membershipId, organizationId, @@ -594,10 +575,7 @@ export const BotRpcLive = BotRpcs.toLayer( role: "member", }) .onConflictDoUpdate({ - target: [ - schema.organizationMembersTable.organizationId, - schema.organizationMembersTable.userId, - ], + target: ["organizationId", "userId"], set: { deletedAt: null }, }), ) @@ -631,11 +609,10 @@ export const BotRpcLive = BotRpcs.toLayer( const bot = botOption.value // Update the machine user's avatar URL - yield* transactionAwareExecute((client) => - client - .update(schema.usersTable) + yield* db.run( + PG.update(schema.usersTable) .set({ avatarUrl, updatedAt: new Date() }) - .where(eq(schema.usersTable.id, bot.userId)), + .where(($) => [$.id.eq(bot.userId)]), ) // Return the bot data (unchanged, but user avatar is updated) diff --git a/apps/backend/src/rpc/handlers/channel-sections.ts b/apps/backend/src/rpc/handlers/channel-sections.ts index 66e4825c9..c6dae8897 100644 --- a/apps/backend/src/rpc/handlers/channel-sections.ts +++ b/apps/backend/src/rpc/handlers/channel-sections.ts @@ -1,5 +1,5 @@ import { ChannelRepo, ChannelSectionRepo } from "@hazel/backend-core" -import { Database, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { ErrorUtils, withRemapDbErrors } from "@hazel/domain" import { ChannelNotFoundError, @@ -7,10 +7,8 @@ import { ChannelSectionResponse, ChannelSectionRpcs, } from "@hazel/domain/rpc" -import { and, eq, inArray, sql } from "drizzle-orm" import { Effect, Option } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" -import { transactionAwareExecute } from "../../lib/transaction-aware-execute" import { withAnnotatedScope } from "../../lib/policy-utils" import { ChannelSectionPolicy } from "../../policies/channel-section-policy" import { OrgResolver } from "../../services/org-resolver" @@ -31,18 +29,12 @@ export const ChannelSectionRpcLive = ChannelSectionRpcs.toLayer( // Calculate next order value if not provided or is 0 let order = payload.order if (order === 0) { - const maxOrderResult = yield* transactionAwareExecute((client) => - client - .select({ - maxOrder: sql`COALESCE(MAX(${schema.channelSectionsTable.order}), -1)`, - }) - .from(schema.channelSectionsTable) - .where( - eq( - schema.channelSectionsTable.organizationId, - payload.organizationId, - ), - ), + const maxOrderResult = yield* db.run( + PG.from(schema.channelSectionsTable) + .select(($) => ({ + maxOrder: PG.coalesce(PG.max($.order), PG.lit(-1)), + })) + .where(($) => [$.organizationId.eq(payload.organizationId)]), ) order = (maxOrderResult[0]?.maxOrder ?? -1) + 1 @@ -101,11 +93,10 @@ export const ChannelSectionRpcLive = ChannelSectionRpcs.toLayer( } // Update all channels in this section to have null sectionId - yield* transactionAwareExecute((client) => - client - .update(schema.channelsTable) + yield* db.run( + PG.update(schema.channelsTable) .set({ sectionId: null }) - .where(eq(schema.channelsTable.sectionId, id)), + .where(($) => [$.sectionId.eq(id)]), ) // Delete the section @@ -123,21 +114,20 @@ export const ChannelSectionRpcLive = ChannelSectionRpcs.toLayer( .transaction( Effect.gen(function* () { yield* channelSectionPolicy.canReorder(organizationId) - yield* transactionAwareExecute((client) => - client - .update(schema.channelSectionsTable) - .set({ - order: sql`CASE id ${sql.join( - sectionIds.map((id, index) => sql`WHEN ${id} THEN ${index}`), - sql` `, - )} END`, - }) - .where( - and( - inArray(schema.channelSectionsTable.id, sectionIds), - eq(schema.channelSectionsTable.organizationId, organizationId), + yield* db.run( + PG.update(schema.channelSectionsTable) + .set(($) => ({ + order: PG.caseWhen( + sectionIds.map( + (id, index) => [$.id.eq(id), PG.lit(index)] as const, + ), + $.order, ), - ), + })) + .where(($) => [ + $.id.in_(...sectionIds), + $.organizationId.eq(organizationId), + ]), ) const txid = yield* generateTransactionId() diff --git a/apps/backend/src/rpc/handlers/channels.ts b/apps/backend/src/rpc/handlers/channels.ts index e348e2e01..05fa76a6f 100644 --- a/apps/backend/src/rpc/handlers/channels.ts +++ b/apps/backend/src/rpc/handlers/channels.ts @@ -5,7 +5,7 @@ import { OrganizationMemberRepo, UserRepo, } from "@hazel/backend-core" -import { Database, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { CurrentUser, DmChannelAlreadyExistsError, @@ -13,12 +13,10 @@ import { withRemapDbErrors, WorkflowServiceUnavailableError, } from "@hazel/domain" -import { OrganizationId } from "@hazel/schema" +import { type MessageId, OrganizationId } from "@hazel/schema" import { ChannelNotFoundError, ChannelResponse, ChannelRpcs, MessageNotFoundError } from "@hazel/domain/rpc" -import { eq } from "drizzle-orm" import { Config, Effect, Option } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" -import { transactionAwareExecute } from "../../lib/transaction-aware-execute" import { ChannelPolicy } from "../../policies/channel-policy" import { UserPolicy } from "../../policies/user-policy" import { BotGatewayService } from "../../services/bot-gateway-service" @@ -411,11 +409,10 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( }) // 4. Link message to thread (direct SQL update to bypass schema restrictions) - yield* transactionAwareExecute((client) => - client - .update(schema.messagesTable) + yield* db.run( + PG.update(schema.messagesTable) .set({ threadChannelId: createdChannel.id }) - .where(eq(schema.messagesTable.id, messageId)), + .where(($) => [$.id.eq(messageId)]), ) yield* channelAccessSync.syncChannel(createdChannel.id) @@ -458,19 +455,19 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( ) } - const originalMessageResult = yield* transactionAwareExecute((client) => - client - .select({ id: schema.messagesTable.id }) - .from(schema.messagesTable) - .where(eq(schema.messagesTable.threadChannelId, channelId)) - .limit(1), - ).pipe(Effect.catchTag("DatabaseError", () => Effect.succeed([]))) + const originalMessageResult = yield* db + .run( + PG.from(schema.messagesTable) + .select("id") + .where(($) => [$.threadChannelId.eq(channelId)]) + .limit(1), + ) + .pipe(Effect.catchTag("DatabaseError", () => Effect.succeed([]))) if (originalMessageResult.length === 0) { return yield* Effect.fail( new MessageNotFoundError({ - messageId: - channelId as unknown as typeof schema.messagesTable.$inferSelect.id, + messageId: channelId as unknown as MessageId, }), ) } diff --git a/apps/backend/src/rpc/handlers/connect-shares.test.ts b/apps/backend/src/rpc/handlers/connect-shares.test.ts index 43d36307a..d2ebeac86 100644 --- a/apps/backend/src/rpc/handlers/connect-shares.test.ts +++ b/apps/backend/src/rpc/handlers/connect-shares.test.ts @@ -107,7 +107,10 @@ describe("connect-shares helpers", () => { remapGuestMountInsertConflict({ error: new Database.DatabaseError({ type: "unique_violation", - cause: { constraint_name: "connect_conv_channels_conversation_org_unique" }, + message: "duplicate key value violates unique constraint", + sqlState: "23505", + constraint: "connect_conv_channels_conversation_org_unique", + cause: new Error("duplicate key value violates unique constraint"), }), guestOrganizationId: GUEST_ORG_ID, findExistingMount: () => Effect.succeed(Option.some({ channelId: GUEST_CHANNEL_ID })), diff --git a/apps/backend/src/rpc/handlers/connect-shares.ts b/apps/backend/src/rpc/handlers/connect-shares.ts index 158b65528..dc9ef1a42 100644 --- a/apps/backend/src/rpc/handlers/connect-shares.ts +++ b/apps/backend/src/rpc/handlers/connect-shares.ts @@ -8,7 +8,7 @@ import { OrganizationMemberRepo, OrganizationRepo, } from "@hazel/backend-core" -import { and, Database, eq, ilike, isNull, or, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { CurrentUser, InternalServerError, @@ -37,7 +37,7 @@ import { ConnectConversationService } from "../../services/connect-conversation- import { OrgResolver } from "../../services/org-resolver" const isUniqueViolation = (error: any, constraintName: string) => - error?.type === "unique_violation" && error?.cause?.constraint_name === constraintName + error?.type === "unique_violation" && error?.constraint === constraintName export const resolveGuestOrganizationForInviteCreate = ({ providedOrgId, @@ -217,29 +217,16 @@ export const ConnectShareRpcLive = ConnectShareRpcs.toLayer( "ConnectShare", "workspace.search", ) - const results = yield* db.makeQuery((execute, input: string) => - execute((client) => - client - .select({ - id: schema.organizationsTable.id, - name: schema.organizationsTable.name, - slug: schema.organizationsTable.slug, - logoUrl: schema.organizationsTable.logoUrl, - }) - .from(schema.organizationsTable) - .where( - and( - isNull(schema.organizationsTable.deletedAt), - eq(schema.organizationsTable.isPublic, true), - or( - ilike(schema.organizationsTable.name, `%${input}%`), - ilike(schema.organizationsTable.slug, `%${input}%`), - ), - ), - ) - .limit(10), - ), - )(query) + const results = yield* db.run( + PG.from(schema.organizationsTable) + .select("id", "name", "slug", "logoUrl") + .where(($) => [ + $.deletedAt.isNull(), + $.isPublic.eq(true), + $.name.ilike(`%${query}%`).or($.slug.ilike(`%${query}%`)), + ]) + .limit(10), + ) const filtered = results.filter((result) => result.id !== organizationId) diff --git a/apps/backend/src/rpc/handlers/integration-requests.ts b/apps/backend/src/rpc/handlers/integration-requests.ts index 9ec42f981..5c362291b 100644 --- a/apps/backend/src/rpc/handlers/integration-requests.ts +++ b/apps/backend/src/rpc/handlers/integration-requests.ts @@ -1,10 +1,9 @@ -import { Database, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { CurrentUser, ErrorUtils, withRemapDbErrors } from "@hazel/domain" import { IntegrationRequestResponse, IntegrationRequestRpcs } from "@hazel/domain/rpc" import { Effect } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" import { withAnnotatedScope } from "../../lib/policy-utils" -import { transactionAwareExecute } from "../../lib/transaction-aware-execute" import { OrgResolver } from "../../services/org-resolver" /** @@ -39,9 +38,8 @@ export const IntegrationRequestRpcLive = IntegrationRequestRpcs.toLayer( ) // Direct database insert - const [result] = yield* transactionAwareExecute((client) => - client - .insert(schema.integrationRequestsTable) + const [result] = yield* db.run( + PG.insertInto(schema.integrationRequestsTable) .values({ organizationId: payload.organizationId, requestedBy: currentUser.id, diff --git a/apps/backend/src/rpc/handlers/organizations.ts b/apps/backend/src/rpc/handlers/organizations.ts index dfcc7e8a5..bf816ca20 100644 --- a/apps/backend/src/rpc/handlers/organizations.ts +++ b/apps/backend/src/rpc/handlers/organizations.ts @@ -45,17 +45,14 @@ const handleOrganizationDbErrors = ( Effect.catchIf( (e): e is Extract => Predicate.isTagged(e, "DatabaseError"), (err): Effect.Effect => { - const dbErr = err as unknown as { - type: string - cause: { constraint_name?: string; detail?: string } - } + const dbErr = err as unknown as Database.DatabaseError // Check if it's a unique violation on the slug column if ( dbErr.type === "unique_violation" && - dbErr.cause.constraint_name === "organizations_slug_unique" + dbErr.constraint === "organizations_slug_unique" ) { // Extract slug from error detail if possible - const slugMatch = dbErr.cause.detail?.match(/Key \(slug\)=\(([^)]+)\)/) + const slugMatch = dbErr.detail?.match(/Key \(slug\)=\(([^)]+)\)/) const slug = slugMatch?.[1] || "unknown" return Effect.fail( new OrganizationSlugAlreadyExistsError({ diff --git a/apps/backend/src/scripts/migrate-to-clerk/backfill-external-ids.ts b/apps/backend/src/scripts/migrate-to-clerk/backfill-external-ids.ts index 59566ec05..8d8d28c23 100644 --- a/apps/backend/src/scripts/migrate-to-clerk/backfill-external-ids.ts +++ b/apps/backend/src/scripts/migrate-to-clerk/backfill-external-ids.ts @@ -18,7 +18,7 @@ * subsequent runs become no-ops. */ -import { Database, and, eq, isNull, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { Effect, Layer, Logger } from "effect" import { DatabaseLive } from "../../services/database" @@ -47,14 +47,11 @@ const program = Effect.gen(function* () { // Pre-flight: count active users in the DB. If it's > mapping.length, some // users have no Clerk equivalent (import failures or users created post-export). - const allActive = yield* db.makeQuery((execute, _data: {}) => - execute((client) => - client - .select({ id: schema.usersTable.id, externalId: schema.usersTable.externalId }) - .from(schema.usersTable) - .where(isNull(schema.usersTable.deletedAt)), - ), - )({}) + const allActive = yield* db.run( + PG.from(schema.usersTable) + .select("id", "externalId") + .where(($) => [$.deletedAt.isNull()]), + ) yield* Effect.log(`Active users in DB: ${allActive.length}`) @@ -99,20 +96,12 @@ const program = Effect.gen(function* () { Effect.gen(function* () { let touched = 0 for (const { workosId, clerkId } of mapping) { - const result = yield* db.makeQuery((execute, args: { workosId: string; clerkId: string }) => - execute((client) => - client - .update(schema.usersTable) - .set({ externalId: args.clerkId, updatedAt: new Date() }) - .where( - and( - eq(schema.usersTable.externalId, args.workosId), - isNull(schema.usersTable.deletedAt), - ), - ) - .returning({ id: schema.usersTable.id }), - ), - )({ workosId, clerkId }) + const result = yield* db.run( + PG.update(schema.usersTable) + .set({ externalId: clerkId, updatedAt: new Date() }) + .where(($) => [$.externalId.eq(workosId), $.deletedAt.isNull()]) + .returning("id"), + ) touched += result.length } return touched @@ -122,23 +111,17 @@ const program = Effect.gen(function* () { yield* Effect.log(`Updated ${updated} rows.`) // Post-flight sanity check. - const remaining = yield* db.makeQuery((execute, _data: {}) => - execute((client) => - client - .select({ count: schema.usersTable.id }) - .from(schema.usersTable) - .where(isNull(schema.usersTable.deletedAt)), - ), - )({}) + const remaining = yield* db.run( + PG.from(schema.usersTable) + .select(($) => ({ count: $.id })) + .where(($) => [$.deletedAt.isNull()]), + ) const clerkShape = /^user_[A-Za-z0-9]{27}$/ - const afterAll = yield* db.makeQuery((execute, _data: {}) => - execute((client) => - client - .select({ id: schema.usersTable.id, externalId: schema.usersTable.externalId }) - .from(schema.usersTable) - .where(isNull(schema.usersTable.deletedAt)), - ), - )({}) + const afterAll = yield* db.run( + PG.from(schema.usersTable) + .select("id", "externalId") + .where(($) => [$.deletedAt.isNull()]), + ) const stillWorkOS = afterAll.filter((u) => workosShape.test(u.externalId)) const properlyClerk = afterAll.filter((u) => clerkShape.test(u.externalId)) diff --git a/apps/backend/src/scripts/migrate-to-clerk/diagnose-org-duplicates.ts b/apps/backend/src/scripts/migrate-to-clerk/diagnose-org-duplicates.ts index ca344da1f..9237f0890 100644 --- a/apps/backend/src/scripts/migrate-to-clerk/diagnose-org-duplicates.ts +++ b/apps/backend/src/scripts/migrate-to-clerk/diagnose-org-duplicates.ts @@ -5,32 +5,22 @@ * channel count, and createdAt so we can see the canonical row vs. the ghost. */ -import { Database, and, eq, isNull, sql, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { Effect, Logger } from "effect" import { DatabaseLive } from "../../services/database" const program = Effect.gen(function* () { const db = yield* Database.Database - const orgs = yield* db.makeQuery((execute, _data: {}) => - execute((client) => - client - .select({ - id: schema.organizationsTable.id, - name: schema.organizationsTable.name, - slug: schema.organizationsTable.slug, - logoUrl: schema.organizationsTable.logoUrl, - settings: schema.organizationsTable.settings, - createdAt: schema.organizationsTable.createdAt, - }) - .from(schema.organizationsTable) - .where(isNull(schema.organizationsTable.deletedAt)), - ), - )({}) + const orgs = yield* db.run( + PG.from(schema.organizationsTable) + .select("id", "name", "slug", "logoUrl", "settings", "createdAt") + .where(($) => [$.deletedAt.isNull()]), + ) yield* Effect.log(`Total active orgs: ${orgs.length}`) - const byName = new Map() + const byName = new Map>() for (const o of orgs) { const key = o.name.trim().toLowerCase() const list = byName.get(key) ?? [] @@ -44,39 +34,24 @@ const program = Effect.gen(function* () { for (const [name, list] of dupes) { yield* Effect.log(`\n=== "${name}" — ${list.length} rows ===`) for (const o of list.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())) { - const memberCount = yield* db.makeQuery((execute, payload: { orgId: string }) => - execute( - (client) => - client.execute(sql` - select count(*)::int as n - from organization_members - where "organizationId" = ${payload.orgId} and "deletedAt" is null - `) as unknown as Promise>, - ), - )({ orgId: o.id }) + const memberCount = yield* db.run( + PG.from(schema.organizationMembersTable) + .select(() => ({ n: PG.count() })) + .where(($) => [$.organizationId.eq(o.id), $.deletedAt.isNull()]), + ) - const channelCount = yield* db.makeQuery((execute, payload: { orgId: string }) => - execute( - (client) => - client.execute(sql` - select count(*)::int as n - from channels - where "organizationId" = ${payload.orgId} and "deletedAt" is null - `) as unknown as Promise>, - ), - )({ orgId: o.id }) + const channelCount = yield* db.run( + PG.from(schema.channelsTable) + .select(() => ({ n: PG.count() })) + .where(($) => [$.organizationId.eq(o.id), $.deletedAt.isNull()]), + ) - const messageCount = yield* db.makeQuery((execute, payload: { orgId: string }) => - execute( - (client) => - client.execute(sql` - select count(*)::int as n - from messages m - join channels c on c.id = m."channelId" - where c."organizationId" = ${payload.orgId} - `) as unknown as Promise>, - ), - )({ orgId: o.id }) + const messageCount = yield* db.run( + PG.from(schema.messagesTable, "m") + .innerJoin(schema.channelsTable, "c", (m, c) => c.id.eq(m.channelId)) + .select(() => ({ n: PG.count() })) + .where(($) => [$.c.organizationId.eq(o.id)]), + ) const clerkOrgId = (o.settings as any)?.clerkOrganizationId ?? "(none)" yield* Effect.log( diff --git a/apps/backend/src/scripts/migrate-to-clerk/merge-org-duplicates.ts b/apps/backend/src/scripts/migrate-to-clerk/merge-org-duplicates.ts index f08ede685..dc1a049f7 100644 --- a/apps/backend/src/scripts/migrate-to-clerk/merge-org-duplicates.ts +++ b/apps/backend/src/scripts/migrate-to-clerk/merge-org-duplicates.ts @@ -29,8 +29,8 @@ * IS_DEV=false bun run src/scripts/migrate-to-clerk/merge-org-duplicates.ts */ -import { Database, and, eq, isNull, sql, schema } from "@hazel/db" -import { Effect, Logger } from "effect" +import { Database, PG, schema } from "@hazel/db" +import { Effect, Logger, Schema } from "effect" import { DatabaseLive } from "../../services/database" const dryRun = process.argv.includes("--dry-run") @@ -38,44 +38,30 @@ const dryRun = process.argv.includes("--dry-run") const program = Effect.gen(function* () { const db = yield* Database.Database - const orgs = yield* db.makeQuery((execute, _data: {}) => - execute((client) => - client - .select({ - id: schema.organizationsTable.id, - name: schema.organizationsTable.name, - slug: schema.organizationsTable.slug, - logoUrl: schema.organizationsTable.logoUrl, - settings: schema.organizationsTable.settings, - createdAt: schema.organizationsTable.createdAt, - }) - .from(schema.organizationsTable) - .where(isNull(schema.organizationsTable.deletedAt)), - ), - )({}) + const orgs = yield* db.run( + PG.from(schema.organizationsTable) + .select("id", "name", "slug", "logoUrl", "settings", "createdAt") + .where(($) => [$.deletedAt.isNull()]), + ) // Count channels + messages per org in one pass. - const counts = yield* db.makeQuery((execute, _data: {}) => - execute( - (client) => - client.execute(sql` - select - o.id as org_id, - (select count(*)::int from channels where "organizationId" = o.id and "deletedAt" is null) as channel_count, - (select count(*)::int from messages m join channels c on c.id = m."channelId" where c."organizationId" = o.id) as message_count, - (select count(*)::int from organization_members where "organizationId" = o.id and "deletedAt" is null) as member_count - from organizations o - where o."deletedAt" is null - `) as unknown as Promise< - Array<{ - org_id: string - channel_count: number - message_count: number - member_count: number - }> - >, - ), - )({}) + const counts = yield* db.query( + Database.sql` + select + o.id as org_id, + (select count(*)::int from channels where "organizationId" = o.id and "deletedAt" is null) as channel_count, + (select count(*)::int from messages m join channels c on c.id = m."channelId" where c."organizationId" = o.id) as message_count, + (select count(*)::int from organization_members where "organizationId" = o.id and "deletedAt" is null) as member_count + from organizations o + where o."deletedAt" is null + `, + Schema.Struct({ + org_id: Schema.String, + channel_count: Schema.Number, + message_count: Schema.Number, + member_count: Schema.Number, + }), + ) const statsById = new Map(counts.map((c) => [c.org_id, c])) @@ -178,53 +164,26 @@ const program = Effect.gen(function* () { clerkOrganizationId: ghost.clerkOrgId, } - yield* db.makeQuery( - ( - execute, - payload: { - id: string - settings: any - logoUrl: string | null - copyLogo: boolean - }, - ) => - execute((client) => - client - .update(schema.organizationsTable) - .set({ - settings: payload.settings, - ...(payload.copyLogo && payload.logoUrl - ? { logoUrl: payload.logoUrl } - : {}), - updatedAt: new Date(), - }) - .where(eq(schema.organizationsTable.id, payload.id as any)), - ), - )({ - id: canonical.id, - settings: mergedSettings, - logoUrl: ghost.logoUrl, - copyLogo: !canonical.logoUrl, - }) + yield* db.run( + PG.update(schema.organizationsTable) + .set({ + settings: mergedSettings, + ...(!canonical.logoUrl && ghost.logoUrl ? { logoUrl: ghost.logoUrl } : {}), + updatedAt: new Date(), + }) + .where(($) => [$.id.eq(canonical.id)]), + ) // Remove the ghost's membership rows — the next webhook fire on the // canonical (now correctly keyed by clerkOrgId) will re-sync real members. - yield* db.makeQuery((execute, payload: { orgId: string }) => - execute((client) => - client - .delete(schema.organizationMembersTable) - .where(eq(schema.organizationMembersTable.organizationId, payload.orgId as any)), - ), - )({ orgId: ghost.id }) + yield* db.run( + PG.deleteFrom(schema.organizationMembersTable).where(($) => [ + $.organizationId.eq(ghost.id), + ]), + ) // Drop the ghost row itself (hard delete — it has no channels/messages). - yield* db.makeQuery((execute, payload: { orgId: string }) => - execute((client) => - client - .delete(schema.organizationsTable) - .where(eq(schema.organizationsTable.id, payload.orgId as any)), - ), - )({ orgId: ghost.id }) + yield* db.run(PG.deleteFrom(schema.organizationsTable).where(($) => [$.id.eq(ghost.id)])) yield* Effect.log(` ✓ merged ghost ${ghost.id} into ${canonical.id} (${canonical.name})`) } diff --git a/packages/setup/src/commands/bots.ts b/packages/setup/src/commands/bots.ts index de4891b0f..8afe72fca 100644 --- a/packages/setup/src/commands/bots.ts +++ b/packages/setup/src/commands/bots.ts @@ -1,5 +1,5 @@ import { Command, Flag, Prompt } from "effect/cli" -import { Database, schema, isNull } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import type { BotId, BotInstallationId, OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" import { Console, Effect, Option, Redacted } from "effect" import { randomUUID } from "crypto" @@ -50,14 +50,10 @@ export const botsCommand = Command.make("bots", { name: nameOption, org: orgOpti })) // Step 2: List available organizations and let user choose - const orgs = yield* db.execute((client) => - client - .select({ - id: schema.organizationsTable.id, - name: schema.organizationsTable.name, - }) - .from(schema.organizationsTable) - .where(isNull(schema.organizationsTable.deletedAt)), + const orgs = yield* db.run( + PG.from(schema.organizationsTable) + .select("id", "name") + .where(($) => [$.deletedAt.isNull()]), ) if (orgs.length === 0) { @@ -88,8 +84,8 @@ export const botsCommand = Command.make("bots", { name: nameOption, org: orgOpti yield* Console.log(`\n${pc.cyan("Creating bot...")}`) // Step 5: Create bot user (machine user) - yield* db.execute((client) => - client.insert(schema.usersTable).values({ + yield* db.run( + PG.insertInto(schema.usersTable).values({ id: botUserId, externalId, email: botEmail, @@ -102,8 +98,8 @@ export const botsCommand = Command.make("bots", { name: nameOption, org: orgOpti yield* Console.log(` ${pc.green("\u2713")} Bot user created`) // Step 6: Create bot record - yield* db.execute((client) => - client.insert(schema.botsTable).values({ + yield* db.run( + PG.insertInto(schema.botsTable).values({ id: botId, userId: botUserId, createdBy: botUserId, @@ -116,8 +112,8 @@ export const botsCommand = Command.make("bots", { name: nameOption, org: orgOpti yield* Console.log(` ${pc.green("\u2713")} Bot record created`) // Step 7: Install bot in organization - yield* db.execute((client) => - client.insert(schema.botInstallationsTable).values({ + yield* db.run( + PG.insertInto(schema.botInstallationsTable).values({ id: installationId, botId, organizationId: orgId, @@ -127,8 +123,8 @@ export const botsCommand = Command.make("bots", { name: nameOption, org: orgOpti yield* Console.log(` ${pc.green("\u2713")} Bot installed in organization`) // Step 8: Add bot user to organization - yield* db.execute((client) => - client.insert(schema.organizationMembersTable).values({ + yield* db.run( + PG.insertInto(schema.organizationMembersTable).values({ id: membershipId, organizationId: orgId, userId: botUserId, From f68f175ef59689e956b7f821daf959d16c4163f9 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 13 Oct 2026 00:43:30 +0200 Subject: [PATCH 6/8] refactor(backend): services and tests on effect-orm Chat-sync worker, side-effect, channel-access sync and connect services query through the PG builder. The chat-sync DB harness migrates its container with @hazel/db/migrate instead of db:push, and tests fake or provide the new Database shape. The Bun backend probes the database at startup with the old retry schedule, since the pool dials lazily. Co-Authored-By: Claude Opus 5.5 --- .../src/services/channel-access-sync.ts | 331 ++++++------------ .../chat-sync-core-worker.integration.test.ts | 90 ++--- .../chat-sync/chat-sync-core-worker.ts | 220 +++++------- .../chat-sync/discord-sync-worker.test.ts | 77 ++-- .../connect-conversation-service.test.ts | 18 +- .../services/connect-conversation-service.ts | 5 +- apps/backend/src/services/database.ts | 45 ++- .../message-outbox-dispatcher.test.ts | 23 +- .../message-side-effect-service.test.ts | 18 +- .../services/message-side-effect-service.ts | 66 ++-- apps/backend/src/test/chat-sync-db-harness.ts | 100 ++---- .../src/test/message-outbox-repo.test.ts | 16 +- 12 files changed, 402 insertions(+), 607 deletions(-) diff --git a/apps/backend/src/services/channel-access-sync.ts b/apps/backend/src/services/channel-access-sync.ts index bc28ae0c9..7609aeb2e 100644 --- a/apps/backend/src/services/channel-access-sync.ts +++ b/apps/backend/src/services/channel-access-sync.ts @@ -1,12 +1,13 @@ -import { and, eq, isNull, notInArray, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import type { ChannelId, ConnectConversationId, OrganizationId, UserId } from "@hazel/schema" import { Context, Effect, Layer } from "effect" -import { transactionAwareExecute } from "../lib/transaction-aware-execute" export class ChannelAccessSyncService extends Context.Service()( "ChannelAccessSyncService", { make: Effect.gen(function* () { + const db = yield* Database.Database + const upsertChannelUsers = Effect.fn("ChannelAccessSyncService.upsertChannelUsers")(function* ( channelId: ChannelId, organizationId: OrganizationId, @@ -14,17 +15,14 @@ export class ChannelAccessSyncService extends Context.Service - client - .delete(schema.channelAccessTable) - .where(eq(schema.channelAccessTable.channelId, channelId)), + yield* db.run( + PG.deleteFrom(schema.channelAccessTable).where(($) => [$.channelId.eq(channelId)]), ) return } - yield* transactionAwareExecute((client) => - client - .insert(schema.channelAccessTable) + yield* db.run( + PG.insertInto(schema.channelAccessTable) .values( dedupedUserIds.map((userId) => ({ userId, @@ -33,7 +31,7 @@ export class ChannelAccessSyncService extends Context.Service - client - .delete(schema.channelAccessTable) - .where( - and( - eq(schema.channelAccessTable.channelId, channelId), - notInArray(schema.channelAccessTable.userId, dedupedUserIds), - ), - ), + yield* db.run( + PG.deleteFrom(schema.channelAccessTable).where(($) => [ + $.channelId.eq(channelId), + $.userId.notIn(...dedupedUserIds), + ]), ) }) const removeChannel = Effect.fn("ChannelAccessSyncService.removeChannel")(function* ( channelId: ChannelId, ) { - yield* transactionAwareExecute((client) => - client - .delete(schema.channelAccessTable) - .where(eq(schema.channelAccessTable.channelId, channelId)), + yield* db.run( + PG.deleteFrom(schema.channelAccessTable).where(($) => [$.channelId.eq(channelId)]), ) }) const syncChannel = Effect.fn("ChannelAccessSyncService.syncChannel")(function* ( channelId: ChannelId, ) { - const channels = yield* transactionAwareExecute((client) => - client - .select({ - id: schema.channelsTable.id, - type: schema.channelsTable.type, - organizationId: schema.channelsTable.organizationId, - parentChannelId: schema.channelsTable.parentChannelId, - }) - .from(schema.channelsTable) - .where( - and( - eq(schema.channelsTable.id, channelId), - isNull(schema.channelsTable.deletedAt), - ), - ) + const channelOption = yield* db.first( + PG.from(schema.channelsTable) + .select("id", "type", "organizationId", "parentChannelId") + .where(($) => [$.id.eq(channelId), $.deletedAt.isNull()]) .limit(1), ) - const channel = channels[0] - if (!channel) { + if (channelOption._tag === "None") { yield* removeChannel(channelId) return } + const channel = channelOption.value let userIds: readonly UserId[] = [] if (channel.type === "public") { - const members = yield* transactionAwareExecute((client) => - client - .select({ userId: schema.organizationMembersTable.userId }) - .from(schema.organizationMembersTable) - .where( - and( - eq( - schema.organizationMembersTable.organizationId, - channel.organizationId, - ), - isNull(schema.organizationMembersTable.deletedAt), - ), - ), + const members = yield* db.run( + PG.from(schema.organizationMembersTable) + .select("userId") + .where(($) => [ + $.organizationId.eq(channel.organizationId), + $.deletedAt.isNull(), + ]), ) userIds = members.map((row) => row.userId) } else if ( @@ -113,30 +88,17 @@ export class ChannelAccessSyncService extends Context.Service - client - .select({ userId: schema.channelMembersTable.userId }) - .from(schema.channelMembersTable) - .innerJoin( - schema.organizationMembersTable, - and( - eq( - schema.organizationMembersTable.organizationId, - channel.organizationId, - ), - eq( - schema.organizationMembersTable.userId, - schema.channelMembersTable.userId, - ), - isNull(schema.organizationMembersTable.deletedAt), + const members = yield* db.run( + PG.from(schema.channelMembersTable, "cm") + .innerJoin(schema.organizationMembersTable, "om", (cm, om) => + PG.and( + om.organizationId.eq(channel.organizationId), + om.userId.eq(cm.userId), + om.deletedAt.isNull(), ), ) - .where( - and( - eq(schema.channelMembersTable.channelId, channel.id), - isNull(schema.channelMembersTable.deletedAt), - ), - ), + .select(($) => ({ userId: $.userId })) + .where(($) => [$.channelId.eq(channel.id), $.deletedAt.isNull()]), ) userIds = members.map((row) => row.userId) } else if (channel.type === "thread") { @@ -146,64 +108,38 @@ export class ChannelAccessSyncService extends Context.Service - client - .select({ userId: schema.channelAccessTable.userId }) - .from(schema.channelAccessTable) - .innerJoin( - schema.organizationMembersTable, - and( - eq( - schema.organizationMembersTable.organizationId, - channel.organizationId, - ), - eq( - schema.organizationMembersTable.userId, - schema.channelAccessTable.userId, - ), - isNull(schema.organizationMembersTable.deletedAt), + const parentAccess = yield* db.run( + PG.from(schema.channelAccessTable, "ca") + .innerJoin(schema.organizationMembersTable, "om", (ca, om) => + PG.and( + om.organizationId.eq(channel.organizationId), + om.userId.eq(ca.userId), + om.deletedAt.isNull(), ), ) - .where( - and( - eq(schema.channelAccessTable.channelId, parentChannelId), - eq(schema.channelAccessTable.organizationId, channel.organizationId), - ), - ), + .select(($) => ({ userId: $.userId })) + .where(($) => [ + $.channelId.eq(parentChannelId), + $.organizationId.eq(channel.organizationId), + ]), ) userIds = parentAccess.map((row) => row.userId) } // If channel is part of a connect conversation, add cross-org participants - const connectMount = yield* transactionAwareExecute((client) => - client - .select({ - conversationId: schema.connectConversationChannelsTable.conversationId, - }) - .from(schema.connectConversationChannelsTable) - .where( - and( - eq(schema.connectConversationChannelsTable.channelId, channelId), - isNull(schema.connectConversationChannelsTable.deletedAt), - ), - ) + const connectMount = yield* db.first( + PG.from(schema.connectConversationChannelsTable) + .select("conversationId") + .where(($) => [$.channelId.eq(channelId), $.deletedAt.isNull()]) .limit(1), ) - if (connectMount[0]) { - const participants = yield* transactionAwareExecute((client) => - client - .select({ userId: schema.connectParticipantsTable.userId }) - .from(schema.connectParticipantsTable) - .where( - and( - eq( - schema.connectParticipantsTable.conversationId, - connectMount[0].conversationId, - ), - isNull(schema.connectParticipantsTable.deletedAt), - ), - ), + if (connectMount._tag === "Some") { + const conversationId = connectMount.value.conversationId + const participants = yield* db.run( + PG.from(schema.connectParticipantsTable) + .select("userId") + .where(($) => [$.conversationId.eq(conversationId), $.deletedAt.isNull()]), ) userIds = [...userIds, ...participants.map((p) => p.userId)] } @@ -214,16 +150,10 @@ export class ChannelAccessSyncService extends Context.Service - client - .select({ channelId: schema.connectConversationChannelsTable.channelId }) - .from(schema.connectConversationChannelsTable) - .where( - and( - eq(schema.connectConversationChannelsTable.conversationId, conversationId), - isNull(schema.connectConversationChannelsTable.deletedAt), - ), - ), + const mounts = yield* db.run( + PG.from(schema.connectConversationChannelsTable) + .select("channelId") + .where(($) => [$.conversationId.eq(conversationId), $.deletedAt.isNull()]), ) yield* Effect.forEach(mounts, (mount) => syncChannel(mount.channelId), { @@ -234,17 +164,14 @@ export class ChannelAccessSyncService extends Context.Service - client - .select({ id: schema.channelsTable.id }) - .from(schema.channelsTable) - .where( - and( - eq(schema.channelsTable.parentChannelId, parentChannelId), - eq(schema.channelsTable.type, "thread"), - isNull(schema.channelsTable.deletedAt), - ), - ), + const childThreads = yield* db.run( + PG.from(schema.channelsTable) + .select("id") + .where(($) => [ + $.parentChannelId.eq(parentChannelId), + $.type.eq("thread"), + $.deletedAt.isNull(), + ]), ) yield* Effect.forEach(childThreads, (thread) => syncChannel(thread.id), { concurrency: 10 }) @@ -252,68 +179,44 @@ export class ChannelAccessSyncService extends Context.Service - client - .select({ id: schema.organizationMembersTable.id }) - .from(schema.organizationMembersTable) - .where( - and( - eq(schema.organizationMembersTable.organizationId, organizationId), - eq(schema.organizationMembersTable.userId, userId), - isNull(schema.organizationMembersTable.deletedAt), - ), - ) + const activeMembership = yield* db.run( + PG.from(schema.organizationMembersTable) + .select("id") + .where(($) => [ + $.organizationId.eq(organizationId), + $.userId.eq(userId), + $.deletedAt.isNull(), + ]) .limit(1), ) if (activeMembership.length === 0) { - yield* transactionAwareExecute((client) => - client - .delete(schema.channelAccessTable) - .where( - and( - eq(schema.channelAccessTable.userId, userId), - eq(schema.channelAccessTable.organizationId, organizationId), - ), - ), + yield* db.run( + PG.deleteFrom(schema.channelAccessTable).where(($) => [ + $.userId.eq(userId), + $.organizationId.eq(organizationId), + ]), ) return } - const channels = yield* transactionAwareExecute((client) => - client - .select({ - id: schema.channelsTable.id, - type: schema.channelsTable.type, - parentChannelId: schema.channelsTable.parentChannelId, - }) - .from(schema.channelsTable) - .where( - and( - eq(schema.channelsTable.organizationId, organizationId), - isNull(schema.channelsTable.deletedAt), - ), - ), + const channels = yield* db.run( + PG.from(schema.channelsTable) + .select("id", "type", "parentChannelId") + .where(($) => [$.organizationId.eq(organizationId), $.deletedAt.isNull()]), ) - const membershipChannels = yield* transactionAwareExecute((client) => - client - .select({ channelId: schema.channelMembersTable.channelId }) - .from(schema.channelMembersTable) - .innerJoin( - schema.channelsTable, - and( - eq(schema.channelsTable.id, schema.channelMembersTable.channelId), - eq(schema.channelsTable.organizationId, organizationId), - isNull(schema.channelsTable.deletedAt), + const membershipChannels = yield* db.run( + PG.from(schema.channelMembersTable, "cm") + .innerJoin(schema.channelsTable, "c", (cm, c) => + PG.and( + c.id.eq(cm.channelId), + c.organizationId.eq(organizationId), + c.deletedAt.isNull(), ), ) - .where( - and( - eq(schema.channelMembersTable.userId, userId), - isNull(schema.channelMembersTable.deletedAt), - ), - ), + .select(($) => ({ channelId: $.channelId })) + .where(($) => [$.userId.eq(userId), $.deletedAt.isNull()]), ) const baseChannelIds = new Set( @@ -335,22 +238,17 @@ export class ChannelAccessSyncService extends Context.Service - client - .delete(schema.channelAccessTable) - .where( - and( - eq(schema.channelAccessTable.userId, userId), - eq(schema.channelAccessTable.organizationId, organizationId), - ), - ), + yield* db.run( + PG.deleteFrom(schema.channelAccessTable).where(($) => [ + $.userId.eq(userId), + $.organizationId.eq(organizationId), + ]), ) return } - yield* transactionAwareExecute((client) => - client - .insert(schema.channelAccessTable) + yield* db.run( + PG.insertInto(schema.channelAccessTable) .values( desiredChannelIds.map((channelId) => ({ userId, @@ -359,10 +257,7 @@ export class ChannelAccessSyncService extends Context.Service - client - .delete(schema.channelAccessTable) - .where( - and( - eq(schema.channelAccessTable.userId, userId), - eq(schema.channelAccessTable.organizationId, organizationId), - notInArray(schema.channelAccessTable.channelId, desiredChannelIds), - ), - ), + yield* db.run( + PG.deleteFrom(schema.channelAccessTable).where(($) => [ + $.userId.eq(userId), + $.organizationId.eq(organizationId), + $.channelId.notIn(...desiredChannelIds), + ]), ) }, ) diff --git a/apps/backend/src/services/chat-sync/chat-sync-core-worker.integration.test.ts b/apps/backend/src/services/chat-sync/chat-sync-core-worker.integration.test.ts index c9e012675..d5b1b52c4 100644 --- a/apps/backend/src/services/chat-sync/chat-sync-core-worker.integration.test.ts +++ b/apps/backend/src/services/chat-sync/chat-sync-core-worker.integration.test.ts @@ -12,7 +12,7 @@ import { OrganizationMemberRepo, UserRepo, } from "@hazel/backend-core" -import { and, Database, eq, isNull, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import type { ChannelId, ExternalChannelId, @@ -152,8 +152,8 @@ const insertBaseContext = (harness: ChatSyncDbHarness) => const botUserId = uuid() const channelId = uuid() - yield* db.execute((client) => - client.insert(schema.organizationsTable).values({ + yield* db.run( + PG.insertInto(schema.organizationsTable).values({ id: organizationId, name: "Chat Sync Org", slug: `chat-sync-${organizationId.slice(0, 8)}`, @@ -164,8 +164,8 @@ const insertBaseContext = (harness: ChatSyncDbHarness) => }), ) - yield* db.execute((client) => - client.insert(schema.usersTable).values([ + yield* db.run( + PG.insertInto(schema.usersTable).values([ { id: authorUserId, externalId: `user-${authorUserId}`, @@ -195,8 +195,8 @@ const insertBaseContext = (harness: ChatSyncDbHarness) => ]), ) - yield* db.execute((client) => - client.insert(schema.channelsTable).values({ + yield* db.run( + PG.insertInto(schema.channelsTable).values({ id: channelId, name: "general", icon: null, @@ -231,8 +231,8 @@ const insertConnection = ( const db = yield* Database.Database const syncConnectionId = uuid() - yield* db.execute((client) => - client.insert(schema.chatSyncConnectionsTable).values({ + yield* db.run( + PG.insertInto(schema.chatSyncConnectionsTable).values({ id: syncConnectionId, organizationId: params.organizationId, integrationConnectionId: null, @@ -268,8 +268,8 @@ const insertLink = ( Effect.gen(function* () { const db = yield* Database.Database const linkId = uuid() - yield* db.execute((client) => - client.insert(schema.chatSyncChannelLinksTable).values({ + yield* db.run( + PG.insertInto(schema.chatSyncChannelLinksTable).values({ id: linkId, syncConnectionId: params.syncConnectionId, hazelChannelId: params.hazelChannelId, @@ -300,8 +300,8 @@ const insertMessage = ( Effect.gen(function* () { const db = yield* Database.Database const messageId = uuid() - yield* db.execute((client) => - client.insert(schema.messagesTable).values({ + yield* db.run( + PG.insertInto(schema.messagesTable).values({ id: messageId, channelId: params.channelId, authorId: params.authorId, @@ -329,8 +329,8 @@ const insertReaction = ( Effect.gen(function* () { const db = yield* Database.Database const reactionId = uuid() - yield* db.execute((client) => - client.insert(schema.messageReactionsTable).values({ + yield* db.run( + PG.insertInto(schema.messageReactionsTable).values({ id: reactionId, messageId: params.messageId, channelId: params.channelId, @@ -464,11 +464,10 @@ describe("ChatSyncCoreWorker integration", () => { await harness.run( Effect.gen(function* () { const db = yield* Database.Database - yield* db.execute((client) => - client - .update(schema.messagesTable) + yield* db.run( + PG.update(schema.messagesTable) .set({ content: "hello-updated", updatedAt: new Date() }) - .where(eq(schema.messagesTable.id, messageId)), + .where(($) => [$.id.eq(messageId)]), ) }), ) @@ -494,20 +493,18 @@ describe("ChatSyncCoreWorker integration", () => { await harness.run( Effect.gen(function* () { const db = yield* Database.Database - const messageLinkRows = yield* db.execute((client) => - client + const messageLinkRows = yield* db.run( + PG.from(schema.chatSyncMessageLinksTable) .select() - .from(schema.chatSyncMessageLinksTable) - .where(eq(schema.chatSyncMessageLinksTable.channelLinkId, linkId)), + .where(($) => [$.channelLinkId.eq(linkId)]), ) expect(messageLinkRows).toHaveLength(1) expect(messageLinkRows[0]?.deletedAt).not.toBeNull() - const receiptRows = yield* db.execute((client) => - client + const receiptRows = yield* db.run( + PG.from(schema.chatSyncEventReceiptsTable) .select() - .from(schema.chatSyncEventReceiptsTable) - .where(eq(schema.chatSyncEventReceiptsTable.syncConnectionId, syncConnectionId)), + .where(($) => [$.syncConnectionId.eq(syncConnectionId)]), ) expect(receiptRows.length).toBeGreaterThanOrEqual(3) expect( @@ -553,11 +550,10 @@ describe("ChatSyncCoreWorker integration", () => { await harness.run( Effect.gen(function* () { const db = yield* Database.Database - const links = yield* db.execute((client) => - client + const links = yield* db.run( + PG.from(schema.chatSyncMessageLinksTable) .select() - .from(schema.chatSyncMessageLinksTable) - .where(eq(schema.chatSyncMessageLinksTable.channelLinkId, linkId)), + .where(($) => [$.channelLinkId.eq(linkId)]), ) expect(links[0]?.externalMessageId).toBe(externalMessageId) }), @@ -595,11 +591,7 @@ describe("ChatSyncCoreWorker integration", () => { await harness.run( Effect.gen(function* () { const db = yield* Database.Database - yield* db.execute((client) => - client - .delete(schema.messageReactionsTable) - .where(eq(schema.messageReactionsTable.id, reactionId)), - ) + yield* db.run(PG.deleteFrom(schema.messageReactionsTable).where(($) => [$.id.eq(reactionId)])) }), ) @@ -743,20 +735,18 @@ describe("ChatSyncCoreWorker integration", () => { await harness.run( Effect.gen(function* () { const db = yield* Database.Database - const linkRows = yield* db.execute((client) => - client + const linkRows = yield* db.run( + PG.from(schema.chatSyncMessageLinksTable) .select() - .from(schema.chatSyncMessageLinksTable) - .where(eq(schema.chatSyncMessageLinksTable.channelLinkId, parentLinkId)), + .where(($) => [$.channelLinkId.eq(parentLinkId)]), ) expect(linkRows).toHaveLength(1) expect(linkRows[0]?.hazelMessageId).toBe(hazelMessageId) - const deletedMessageRows = yield* db.execute((client) => - client + const deletedMessageRows = yield* db.run( + PG.from(schema.messagesTable) .select() - .from(schema.messagesTable) - .where(eq(schema.messagesTable.id, hazelMessageId)), + .where(($) => [$.id.eq(hazelMessageId)]), ) expect(deletedMessageRows[0]?.deletedAt).not.toBeNull() }), @@ -887,16 +877,10 @@ describe("ChatSyncCoreWorker integration", () => { await harness.run( Effect.gen(function* () { const db = yield* Database.Database - const rows = yield* db.execute((client) => - client + const rows = yield* db.run( + PG.from(schema.messagesTable) .select() - .from(schema.messagesTable) - .where( - and( - eq(schema.messagesTable.channelId, ctx.channelId), - isNull(schema.messagesTable.deletedAt), - ), - ), + .where(($) => [$.channelId.eq(ctx.channelId), $.deletedAt.isNull()]), ) const contents = rows.map((row) => row.content) expect(contents).toContain("direction-check") diff --git a/apps/backend/src/services/chat-sync/chat-sync-core-worker.ts b/apps/backend/src/services/chat-sync/chat-sync-core-worker.ts index d07280b1c..f50b3aa4c 100644 --- a/apps/backend/src/services/chat-sync/chat-sync-core-worker.ts +++ b/apps/backend/src/services/chat-sync/chat-sync-core-worker.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto" -import { and, asc, Database, eq, isNull, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { ChannelRepo, ChatSyncChannelLinkRepo, @@ -29,7 +29,6 @@ import { ExternalThreadId, } from "@hazel/schema" import { Context, Config, Effect, Layer, Option, Redacted, Schema } from "effect" -import { transactionAwareExecute } from "../../lib/transaction-aware-execute" import { ChannelAccessSyncService } from "../channel-access-sync" import { IntegrationBotService } from "../integrations/integration-bot-service" import { ChatSyncProviderRegistry } from "./chat-sync-provider-registry" @@ -244,22 +243,18 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk const listMessageAttachmentsForOutboundSync = Effect.fn( "discordSyncWorker.listMessageAttachmentsForOutboundSync", )(function* (hazelMessageId: MessageId) { - const rows = yield* db.execute((client) => - client - .select({ - id: schema.attachmentsTable.id, - fileName: schema.attachmentsTable.fileName, - fileSize: schema.attachmentsTable.fileSize, - }) - .from(schema.attachmentsTable) - .where( - and( - eq(schema.attachmentsTable.messageId, hazelMessageId), - eq(schema.attachmentsTable.status, "complete"), - isNull(schema.attachmentsTable.deletedAt), - ), - ) - .orderBy(asc(schema.attachmentsTable.uploadedAt), asc(schema.attachmentsTable.id)), + const rows = yield* db.run( + PG.from(schema.attachmentsTable) + .select("id", "fileName", "fileSize") + .where(($) => [ + $.messageId.eq(hazelMessageId), + $.status.eq("complete"), + $.deletedAt.isNull(), + ]) + .orderBy(($) => [ + [$.uploadedAt, "asc"], + [$.id, "asc"], + ]), ) if (rows.length === 0) { @@ -877,32 +872,20 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk } } - const links = yield* db.execute((client) => - client - .select({ - externalMessageId: schema.chatSyncMessageLinksTable.externalMessageId, - }) - .from(schema.chatSyncMessageLinksTable) + const links = yield* db.run( + PG.from(schema.chatSyncMessageLinksTable, "message_link") .innerJoin( schema.chatSyncChannelLinksTable, - and( - eq( - schema.chatSyncChannelLinksTable.id, - schema.chatSyncMessageLinksTable.channelLinkId, + "channel_link", + (messageLink, channelLink) => + PG.and( + channelLink.id.eq(messageLink.channelLinkId), + channelLink.syncConnectionId.eq(params.syncConnectionId), + channelLink.deletedAt.isNull(), ), - eq( - schema.chatSyncChannelLinksTable.syncConnectionId, - params.syncConnectionId, - ), - isNull(schema.chatSyncChannelLinksTable.deletedAt), - ), - ) - .where( - and( - eq(schema.chatSyncMessageLinksTable.hazelMessageId, params.hazelMessageId), - isNull(schema.chatSyncMessageLinksTable.deletedAt), - ), ) + .select(($) => ({ externalMessageId: $.externalMessageId })) + .where(($) => [$.hazelMessageId.eq(params.hazelMessageId), $.deletedAt.isNull()]) .limit(1), ) return Option.fromNullishOr(links[0]?.externalMessageId as ExternalMessageId | undefined) @@ -926,35 +909,23 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk } } - const links = yield* db.execute((client) => - client - .select({ - hazelMessageId: schema.chatSyncMessageLinksTable.hazelMessageId, - }) - .from(schema.chatSyncMessageLinksTable) + const links = yield* db.run( + PG.from(schema.chatSyncMessageLinksTable, "message_link") .innerJoin( schema.chatSyncChannelLinksTable, - and( - eq( - schema.chatSyncChannelLinksTable.id, - schema.chatSyncMessageLinksTable.channelLinkId, + "channel_link", + (messageLink, channelLink) => + PG.and( + channelLink.id.eq(messageLink.channelLinkId), + channelLink.syncConnectionId.eq(params.syncConnectionId), + channelLink.deletedAt.isNull(), ), - eq( - schema.chatSyncChannelLinksTable.syncConnectionId, - params.syncConnectionId, - ), - isNull(schema.chatSyncChannelLinksTable.deletedAt), - ), - ) - .where( - and( - eq( - schema.chatSyncMessageLinksTable.externalMessageId, - params.externalMessageId, - ), - isNull(schema.chatSyncMessageLinksTable.deletedAt), - ), ) + .select(($) => ({ hazelMessageId: $.hazelMessageId })) + .where(($) => [ + $.externalMessageId.eq(params.externalMessageId), + $.deletedAt.isNull(), + ]) .limit(1), ) return Option.fromNullishOr(links[0]?.hazelMessageId) @@ -1009,19 +980,14 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk } const parentLink = parentLinkOption.value - const rootMessages = yield* db.execute((client) => - client - .select({ - id: schema.messagesTable.id, - }) - .from(schema.messagesTable) - .where( - and( - eq(schema.messagesTable.threadChannelId, params.hazelChannelId), - isNull(schema.messagesTable.deletedAt), - ), - ) - .orderBy(asc(schema.messagesTable.createdAt), asc(schema.messagesTable.id)) + const rootMessages = yield* db.run( + PG.from(schema.messagesTable) + .select("id") + .where(($) => [$.threadChannelId.eq(params.hazelChannelId), $.deletedAt.isNull()]) + .orderBy(($) => [ + [$.createdAt, "asc"], + [$.id, "asc"], + ]) .limit(1), ) const rootMessageId = rootMessages[0]?.id @@ -1256,28 +1222,25 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk let failed = 0 for (const link of links) { - const unsyncedMessages = yield* db.execute((client) => - client - .select({ - id: schema.messagesTable.id, - }) - .from(schema.messagesTable) - .leftJoin( - schema.chatSyncMessageLinksTable, - and( - eq(schema.chatSyncMessageLinksTable.channelLinkId, link.id), - eq(schema.chatSyncMessageLinksTable.hazelMessageId, schema.messagesTable.id), - isNull(schema.chatSyncMessageLinksTable.deletedAt), + const unsyncedMessages = yield* db.run( + PG.from(schema.messagesTable, "message") + .leftJoin(schema.chatSyncMessageLinksTable, "message_link", (message, messageLink) => + PG.and( + messageLink.channelLinkId.eq(link.id), + messageLink.hazelMessageId.eq(message.id), + messageLink.deletedAt.isNull(), ), ) - .where( - and( - eq(schema.messagesTable.channelId, link.hazelChannelId), - isNull(schema.messagesTable.deletedAt), - isNull(schema.chatSyncMessageLinksTable.id), - ), - ) - .orderBy(asc(schema.messagesTable.createdAt), asc(schema.messagesTable.id)) + .select(($) => ({ id: $.id })) + .where(($) => [ + $.channelId.eq(link.hazelChannelId), + $.deletedAt.isNull(), + $.message_link.id.isNull(), + ]) + .orderBy(($) => [ + [$.createdAt, "asc"], + [$.id, "asc"], + ]) .limit(maxMessagesPerChannel), ) @@ -1653,18 +1616,10 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk const messageLink = messageLinkOption.value const normalizedMessageLink = normalizeMessageLinkExternalId(messageLink) - const remainingReactions = yield* db.execute((client) => - client - .select({ - id: schema.messageReactionsTable.id, - }) - .from(schema.messageReactionsTable) - .where( - and( - eq(schema.messageReactionsTable.messageId, payload.hazelMessageId), - eq(schema.messageReactionsTable.emoji, payload.emoji), - ), - ) + const remainingReactions = yield* db.run( + PG.from(schema.messageReactionsTable) + .select("id") + .where(($) => [$.messageId.eq(payload.hazelMessageId), $.emoji.eq(payload.emoji)]) .limit(1), ) if (remainingReactions.length > 0) { @@ -1711,31 +1666,24 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk hazelChannelId: ChannelId, provider: ChatSyncProvider, ) { - const targets = yield* db.execute((client) => - client - .select({ - syncConnectionId: schema.chatSyncConnectionsTable.id, - channelLinkId: schema.chatSyncChannelLinksTable.id, - direction: schema.chatSyncChannelLinksTable.direction, - }) - .from(schema.chatSyncChannelLinksTable) - .innerJoin( - schema.chatSyncConnectionsTable, - eq( - schema.chatSyncConnectionsTable.id, - schema.chatSyncChannelLinksTable.syncConnectionId, - ), + const targets = yield* db.run( + PG.from(schema.chatSyncChannelLinksTable, "channel_link") + .innerJoin(schema.chatSyncConnectionsTable, "connection", (link, connection) => + connection.id.eq(link.syncConnectionId), ) - .where( - and( - eq(schema.chatSyncChannelLinksTable.hazelChannelId, hazelChannelId), - eq(schema.chatSyncChannelLinksTable.isActive, true), - isNull(schema.chatSyncChannelLinksTable.deletedAt), - eq(schema.chatSyncConnectionsTable.provider, provider), - eq(schema.chatSyncConnectionsTable.status, "active"), - isNull(schema.chatSyncConnectionsTable.deletedAt), - ), - ), + .select(($) => ({ + syncConnectionId: $.connection.id, + channelLinkId: $.id, + direction: $.direction, + })) + .where(($) => [ + $.hazelChannelId.eq(hazelChannelId), + $.isActive.eq(true), + $.deletedAt.isNull(), + $.connection.provider.eq(provider), + $.connection.status.eq("active"), + $.connection.deletedAt.isNull(), + ]), ) return targets }) @@ -2091,8 +2039,8 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk if (normalizedExternalAttachments.length > 0) { const uploadedAtBase = Date.now() - yield* transactionAwareExecute((client) => - client.insert(schema.attachmentsTable).values( + yield* db.run( + PG.insertInto(schema.attachmentsTable).values( normalizedExternalAttachments.map((attachment, index) => ({ organizationId: connection.organizationId, channelId: link.hazelChannelId, diff --git a/apps/backend/src/services/chat-sync/discord-sync-worker.test.ts b/apps/backend/src/services/chat-sync/discord-sync-worker.test.ts index 05f4ef210..c98ab88c3 100644 --- a/apps/backend/src/services/chat-sync/discord-sync-worker.test.ts +++ b/apps/backend/src/services/chat-sync/discord-sync-worker.test.ts @@ -12,7 +12,7 @@ import { OrganizationMemberRepo, UserRepo, } from "@hazel/backend-core" -import { Database, type TxFn } from "@hazel/db" +import { Database } from "@hazel/db" import type { ChannelId, ExternalChannelId, @@ -123,9 +123,19 @@ type WorkerLayerDeps = { channelAccessSyncService: unknown providerRegistry?: unknown discordApiClient?: unknown - databaseExecute?: (query: unknown) => Effect.Effect + databaseRun?: (query: unknown) => Effect.Effect } +/** The rows of a `PG.insertInto(...).values(rows)` the worker handed to `db.run`, if it was an insert. */ +const insertedRows = (query: unknown): Array | undefined => + typeof query === "object" && + query !== null && + "_tag" in query && + query._tag === "CHInsert" && + "_state" in query + ? [...((query as { _state: { rows?: ReadonlyArray } })._state.rows ?? [])] + : undefined + type DiscordSyncWorkerShape = Context.Service.Shape const PAYLOAD: DiscordIngressMessageCreate = { @@ -196,16 +206,15 @@ const makeWorkerLayer = (deps: WorkerLayerDeps) => ), Layer.provide( Layer.succeed(Database.Database, { - execute: (query: unknown) => - deps.databaseExecute ? deps.databaseExecute(query) : Effect.succeed([]), - transaction: (effect: any) => - Effect.provideService(effect, Database.TransactionContext, { - execute: ((query: unknown) => - deps.databaseExecute ? deps.databaseExecute(query) : Effect.succeed([])) as TxFn, - }), - makeQuery: () => Effect.die("not used in this test"), - makeQueryWithSchema: () => Effect.die("not used in this test"), - } as any), + run: (query: unknown) => (deps.databaseRun ? deps.databaseRun(query) : Effect.succeed([])), + first: (query: unknown) => + (deps.databaseRun ? deps.databaseRun(query) : Effect.succeed([])).pipe( + Effect.map((rows) => Option.fromNullishOr((rows as ReadonlyArray)[0])), + ), + query: () => Effect.die("not used in this test"), + execute: () => Effect.die("not used in this test"), + transaction: (effect: Effect.Effect) => effect, + } as unknown as Database.DatabaseShape), ), Layer.provide( Layer.succeed( @@ -530,17 +539,11 @@ describe("DiscordSyncWorker inbound attachment records", () => { createThread: () => Effect.succeed("thread-id"), }), } as unknown as ChatSyncProviderRegistry, - databaseExecute: (query) => + databaseRun: (query) => Effect.sync(() => { - if (typeof query === "function") { - query({ - insert: () => ({ - values: (rows: Array) => { - insertedAttachmentRows = rows - return [] - }, - }), - } as any) + const rows = insertedRows(query) + if (rows) { + insertedAttachmentRows = rows } return [] }), @@ -571,7 +574,7 @@ describe("DiscordSyncWorker inbound attachment records", () => { it("keeps plain content unchanged and does not write attachment rows when there are no attachments", async () => { let insertedContent: string | null = null - let databaseExecuteCalls = 0 + let databaseRunCalls = 0 const layer = makeWorkerLayer({ connectionRepo: { @@ -633,9 +636,9 @@ describe("DiscordSyncWorker inbound attachment records", () => { createThread: () => Effect.succeed("thread-id"), }), } as unknown as ChatSyncProviderRegistry, - databaseExecute: () => + databaseRun: () => Effect.sync(() => { - databaseExecuteCalls += 1 + databaseRunCalls += 1 return [] }), }) @@ -649,7 +652,7 @@ describe("DiscordSyncWorker inbound attachment records", () => { expect(result.status).toBe("created") expect(insertedContent).toBe("plain text from discord") - expect(databaseExecuteCalls).toBe(0) + expect(databaseRunCalls).toBe(0) }) it("writes only valid attachment rows when payload includes malformed attachment items", async () => { @@ -716,17 +719,11 @@ describe("DiscordSyncWorker inbound attachment records", () => { createThread: () => Effect.succeed("thread-id"), }), } as unknown as ChatSyncProviderRegistry, - databaseExecute: (query) => + databaseRun: (query) => Effect.sync(() => { - if (typeof query === "function") { - query({ - insert: () => ({ - values: (rows: Array) => { - insertedAttachmentRows = rows - return [] - }, - }), - } as any) + const rows = insertedRows(query) + if (rows) { + insertedAttachmentRows = rows } return [] }), @@ -2952,7 +2949,7 @@ describe("DiscordSyncWorker outbound attachments primitive", () => { createThread: () => Effect.succeed("thread-id"), }), } as unknown as ChatSyncProviderRegistry, - databaseExecute: () => + databaseRun: () => Effect.succeed([ { id: "00000000-0000-4000-8000-000000000101", @@ -3053,7 +3050,7 @@ describe("DiscordSyncWorker outbound attachments primitive", () => { createThread: () => Effect.succeed("thread-id"), }), } as unknown as ChatSyncProviderRegistry, - databaseExecute: () => Effect.succeed([]), + databaseRun: () => Effect.succeed([]), }) const result = await runWorkerEffect( @@ -3170,7 +3167,7 @@ describe("DiscordSyncWorker outbound attachments primitive", () => { updateWebhookMessage: () => Effect.succeed(undefined), deleteWebhookMessage: () => Effect.succeed(undefined), } as unknown as Discord.DiscordApiClient, - databaseExecute: () => + databaseRun: () => Effect.succeed([ { id: "00000000-0000-4000-8000-000000000201", @@ -3258,7 +3255,7 @@ describe("DiscordSyncWorker outbound attachments primitive", () => { createThread: () => Effect.succeed("thread-id"), }), } as unknown as ChatSyncProviderRegistry, - databaseExecute: () => + databaseRun: () => Effect.succeed([ { id: "00000000-0000-4000-8000-000000000301", diff --git a/apps/backend/src/services/connect-conversation-service.test.ts b/apps/backend/src/services/connect-conversation-service.test.ts index 83c9d4acc..3d6b22ea2 100644 --- a/apps/backend/src/services/connect-conversation-service.test.ts +++ b/apps/backend/src/services/connect-conversation-service.test.ts @@ -256,7 +256,9 @@ describe("ConnectConversationService", () => { Effect.fail( new Database.DatabaseError({ type: "unique_violation", - cause: { constraint_name: "connect_conversations_host_channel_unique" }, + message: "duplicate key value violates unique constraint", + constraint: "connect_conversations_host_channel_unique", + cause: null, }), ), findByHostChannel: () => Effect.succeed(Option.some(existingConversation)), @@ -276,7 +278,9 @@ describe("ConnectConversationService", () => { Effect.fail( new Database.DatabaseError({ type: "unique_violation", - cause: { constraint_name: "connect_conv_channels_channel_unique" }, + message: "duplicate key value violates unique constraint", + constraint: "connect_conv_channels_channel_unique", + cause: null, }), ), findByConversationId: () => Effect.succeed([existingMount]), @@ -399,7 +403,7 @@ describe("ConnectConversationService", () => { MessageRepo, serviceShape({ backfillConversationIdForChannel: () => - Effect.serviceOption(Database.TransactionContext).pipe( + Effect.serviceOption(Database.Transaction).pipe( Effect.tap((maybeTx) => Effect.sync(() => { transactionChecks.push(Option.isSome(maybeTx)) @@ -415,7 +419,7 @@ describe("ConnectConversationService", () => { MessageReactionRepo, serviceShape({ backfillConversationIdForChannel: () => - Effect.serviceOption(Database.TransactionContext).pipe( + Effect.serviceOption(Database.Transaction).pipe( Effect.tap((maybeTx) => Effect.sync(() => { transactionChecks.push(Option.isSome(maybeTx)) @@ -433,8 +437,10 @@ describe("ConnectConversationService", () => { await Effect.runPromise( useService((service) => service.ensureChannelConversation(HOST_CHANNEL_ID, HOST_USER_ID)).pipe( Effect.provide(layer), - Effect.provideService(Database.TransactionContext, { - execute: (fn) => Effect.promise(() => fn({} as never)), + Effect.provideService(Database.Transaction, { + depth: 0, + isolationLevel: undefined, + accessMode: undefined, }), Effect.orDie, ), diff --git a/apps/backend/src/services/connect-conversation-service.ts b/apps/backend/src/services/connect-conversation-service.ts index 8d48c60da..494490692 100644 --- a/apps/backend/src/services/connect-conversation-service.ts +++ b/apps/backend/src/services/connect-conversation-service.ts @@ -6,6 +6,7 @@ import { MessageReactionRepo, MessageRepo, } from "@hazel/backend-core" +import type { DatabaseError } from "@hazel/db" import { InternalServerError } from "@hazel/domain" import type { ChannelId, ConnectConversationId, OrganizationId, UserId } from "@hazel/schema" import { Context, Effect, Layer, Option } from "effect" @@ -25,8 +26,8 @@ export class ConnectConversationService extends Context.Service - error?.type === "unique_violation" && error?.cause?.constraint_name === constraintName + const isUniqueViolation = (error: DatabaseError, constraintName: string) => + error.type === "unique_violation" && error.constraint === constraintName const getHostConversationOrFail = (channelId: ChannelId) => connectConversationRepo.findByHostChannel(channelId).pipe( diff --git a/apps/backend/src/services/database.ts b/apps/backend/src/services/database.ts index de3419d68..3e0fbded3 100644 --- a/apps/backend/src/services/database.ts +++ b/apps/backend/src/services/database.ts @@ -1,13 +1,42 @@ import { Database } from "@hazel/db" -import { Effect, Layer } from "effect" +import { Effect, Layer, Schedule } from "effect" import { EnvVars } from "../lib/env-vars" -export const DatabaseLive = Layer.unwrap( +/** + * Checks the database is reachable at startup, retrying for a while before giving up: the pool + * itself connects lazily, so without this a bad DATABASE_URL would only surface on the first query. + */ +const ConnectionCheck = Layer.effectDiscard( Effect.gen(function* () { - const envVars = yield* EnvVars - return Database.layer({ - url: envVars.DATABASE_URL, - ssl: !envVars.IS_DEV, - }) + const db = yield* Database.Database + yield* db.execute(Database.sql`SELECT 1`).pipe( + Effect.retry( + Schedule.jittered(Schedule.spaced("1.25 seconds")).pipe( + (schedule) => Schedule.max([schedule, Schedule.recurs(10)]), + Schedule.tap(({ attempt }) => + Effect.logWarning( + `[Database client]: Connection to the database failed. Retrying (attempt ${attempt}).`, + ), + ), + ), + ), + Effect.tap(() => Effect.logInfo("[Database client]: Connection to the database established.")), + Effect.orDie, + ) }), -).pipe(Layer.provide(EnvVars.layer)) +) + +export const DatabaseLive = ConnectionCheck.pipe( + Layer.provideMerge( + Layer.unwrap( + Effect.gen(function* () { + const envVars = yield* EnvVars + return Database.layer({ + url: envVars.DATABASE_URL, + ssl: !envVars.IS_DEV, + }) + }), + ).pipe(Layer.orDie), + ), + Layer.provide(EnvVars.layer), +) diff --git a/apps/backend/src/services/message-outbox-dispatcher.test.ts b/apps/backend/src/services/message-outbox-dispatcher.test.ts index 6e4626f8d..1f8a6036a 100644 --- a/apps/backend/src/services/message-outbox-dispatcher.test.ts +++ b/apps/backend/src/services/message-outbox-dispatcher.test.ts @@ -7,7 +7,7 @@ import { type ReactionCreatedPayload, type ReactionDeletedPayload, } from "@hazel/backend-core" -import { Database, asc, eq, inArray, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import type { ChannelId, MessageId, MessageOutboxEventId, UserId } from "@hazel/schema" import { Effect, Layer, Redacted, Context } from "effect" import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" @@ -118,11 +118,8 @@ const getOutboxRows = (harness: ChatSyncDbHarness) => harness.run( Effect.gen(function* () { const db = yield* Database.Database - return yield* db.execute((client) => - client - .select() - .from(schema.messageOutboxEventsTable) - .orderBy(asc(schema.messageOutboxEventsTable.sequence)), + return yield* db.run( + PG.from(schema.messageOutboxEventsTable).select().orderBy(["sequence", "asc"]), ) }), ) @@ -131,11 +128,10 @@ const getOutboxRowByIds = (harness: ChatSyncDbHarness, ids: ReadonlyArray - client + return yield* db.run( + PG.from(schema.messageOutboxEventsTable) .select() - .from(schema.messageOutboxEventsTable) - .where(inArray(schema.messageOutboxEventsTable.id, [...ids])), + .where(($) => [$.id.in_(...ids)]), ) }), ) @@ -272,13 +268,12 @@ describe("MessageOutboxDispatcher", () => { }, }) - yield* db.execute((client) => - client - .update(schema.messageOutboxEventsTable) + yield* db.run( + PG.update(schema.messageOutboxEventsTable) .set({ attemptCount: 24, }) - .where(eq(schema.messageOutboxEventsTable.id, inserted.id)), + .where(($) => [$.id.eq(inserted.id)]), ) return [inserted] as const diff --git a/apps/backend/src/services/message-side-effect-service.test.ts b/apps/backend/src/services/message-side-effect-service.test.ts index acbf31ee7..74a480f6a 100644 --- a/apps/backend/src/services/message-side-effect-service.test.ts +++ b/apps/backend/src/services/message-side-effect-service.test.ts @@ -1,6 +1,6 @@ import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http" import { randomUUID } from "node:crypto" -import { Database, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import type { ChannelId, MessageId, MessageReactionId, OrganizationId, UserId } from "@hazel/schema" import { Effect, Layer, Context } from "effect" import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" @@ -107,8 +107,8 @@ const seedMessageSideEffectState = (harness: ChatSyncDbHarness) => Effect.gen(function* () { const db = yield* Database.Database - yield* db.execute((client) => - client.insert(schema.organizationsTable).values({ + yield* db.run( + PG.insertInto(schema.organizationsTable).values({ id: ORG_ID, name: "Outbox Org", slug: "outbox-org", @@ -119,8 +119,8 @@ const seedMessageSideEffectState = (harness: ChatSyncDbHarness) => }), ) - yield* db.execute((client) => - client.insert(schema.usersTable).values([ + yield* db.run( + PG.insertInto(schema.usersTable).values([ { id: AUTHOR_ID, externalId: `user-${AUTHOR_ID}`, @@ -150,8 +150,8 @@ const seedMessageSideEffectState = (harness: ChatSyncDbHarness) => ]), ) - yield* db.execute((client) => - client.insert(schema.channelsTable).values([ + yield* db.run( + PG.insertInto(schema.channelsTable).values([ { id: CHANNEL_ID, name: "general", @@ -175,7 +175,7 @@ const seedMessageSideEffectState = (harness: ChatSyncDbHarness) => ]), ) - const messagesToInsert: Array = [ + const messagesToInsert: Array = [ { id: ORIGINAL_MESSAGE_ID, channelId: CHANNEL_ID, @@ -205,7 +205,7 @@ const seedMessageSideEffectState = (harness: ChatSyncDbHarness) => }, ] - yield* db.execute((client) => client.insert(schema.messagesTable).values(messagesToInsert)) + yield* db.run(PG.insertInto(schema.messagesTable).values(messagesToInsert)) }), ) diff --git a/apps/backend/src/services/message-side-effect-service.ts b/apps/backend/src/services/message-side-effect-service.ts index b9c37eec7..5f348f06c 100644 --- a/apps/backend/src/services/message-side-effect-service.ts +++ b/apps/backend/src/services/message-side-effect-service.ts @@ -1,4 +1,4 @@ -import { and, Database, eq, isNull, schema, sql } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import { WorkflowInitializationError } from "@hazel/domain" import { Context, Array, Config, Effect, Layer, Option } from "effect" import type { @@ -24,25 +24,16 @@ export class MessageSideEffectService extends Context.Service - dbClient - .select({ id: schema.usersTable.id }) - .from(schema.usersTable) - .where( - and( - eq(schema.usersTable.externalId, "integration-bot-discord"), - isNull(schema.usersTable.deletedAt), - ), - ) + const integrationBot = yield* db + .first( + PG.from(schema.usersTable) + .select("id") + .where(($) => [$.externalId.eq("integration-bot-discord"), $.deletedAt.isNull()]) .limit(1), ) - .pipe(Effect.catchTag("DatabaseError", () => Effect.succeed([]))) + .pipe(Effect.catchTag("DatabaseError", () => Effect.succeed(Option.none()))) - return Array.head(integrationBotResult).pipe( - Option.map((row) => row.id), - Option.getOrNull, - ) + return Option.getOrNull(Option.map(integrationBot, (row) => row.id)) }) const handleMessageCreated = Effect.fn("MessageSideEffectService.handleMessageCreated")( @@ -68,14 +59,10 @@ export class MessageSideEffectService extends Context.Service - dbClient - .select({ - type: schema.channelsTable.type, - name: schema.channelsTable.name, - }) - .from(schema.channelsTable) - .where(eq(schema.channelsTable.id, payload.channelId)) + .run( + PG.from(schema.channelsTable) + .select("type", "name") + .where(($) => [$.id.eq(payload.channelId)]) .limit(1), ) .pipe( @@ -132,16 +119,10 @@ export class MessageSideEffectService extends Context.Service - dbClient - .select({ count: sql`count(*)::int` }) - .from(schema.messagesTable) - .where( - and( - eq(schema.messagesTable.channelId, payload.channelId), - isNull(schema.messagesTable.deletedAt), - ), - ), + .run( + PG.from(schema.messagesTable) + .select(() => ({ count: PG.count() })) + .where(($) => [$.channelId.eq(payload.channelId), $.deletedAt.isNull()]), ) .pipe(Effect.catchTag("DatabaseError", () => Effect.succeed([{ count: 0 }]))) @@ -157,17 +138,14 @@ export class MessageSideEffectService extends Context.Service - dbClient - .select({ id: schema.messagesTable.id }) - .from(schema.messagesTable) - .where(eq(schema.messagesTable.threadChannelId, payload.channelId)) + const originalMessage = yield* db + .first( + PG.from(schema.messagesTable) + .select("id") + .where(($) => [$.threadChannelId.eq(payload.channelId)]) .limit(1), ) - .pipe(Effect.catchTag("DatabaseError", () => Effect.succeed([]))) - - const originalMessage = Array.head(originalMessageResult) + .pipe(Effect.catchTag("DatabaseError", () => Effect.succeed(Option.none()))) if (Option.isNone(originalMessage)) { return } diff --git a/apps/backend/src/test/chat-sync-db-harness.ts b/apps/backend/src/test/chat-sync-db-harness.ts index b554bf947..a45dc7a73 100644 --- a/apps/backend/src/test/chat-sync-db-harness.ts +++ b/apps/backend/src/test/chat-sync-db-harness.ts @@ -1,9 +1,8 @@ import { execFileSync } from "node:child_process" -import { fileURLToPath } from "node:url" import { Database } from "@hazel/db" -import { Effect, Layer, Redacted } from "effect" +import { migrateUrl } from "@hazel/db/migrate" +import { Effect, Layer, Redacted, Schema } from "effect" -const DB_PACKAGE_DIR = fileURLToPath(new URL("../../../../packages/db", import.meta.url)) const POSTGRES_IMAGE = "postgres:17-alpine" const POSTGRES_USER = "user" const POSTGRES_PASSWORD = "password" @@ -45,17 +44,6 @@ export interface ChatSyncDbHarness { stop: () => Promise } -const runDbPush = (databaseUrl: string) => { - execFileSync("bun", ["run", "db:push"], { - cwd: DB_PACKAGE_DIR, - stdio: "pipe", - env: { - ...process.env, - DATABASE_URL: databaseUrl, - }, - }) -} - const runDocker = (args: ReadonlyArray, options?: { stdio?: "pipe" | "ignore" }) => execFileSync("docker", [...args], { encoding: "utf8", @@ -69,9 +57,24 @@ const waitForPostgres = async (containerId: string) => { while (Date.now() < deadline) { try { - runDocker(["exec", containerId, "pg_isready", "-U", POSTGRES_USER, "-d", POSTGRES_DB], { - stdio: "ignore", - }) + // Over TCP: the image's init-time server listens only on the Unix socket and is then + // restarted, so a socket check can pass before the real server accepts connections. + runDocker( + [ + "exec", + containerId, + "pg_isready", + "-h", + "127.0.0.1", + "-U", + POSTGRES_USER, + "-d", + POSTGRES_DB, + ], + { + stdio: "ignore", + }, + ) return } catch { await sleep(POLL_INTERVAL_MS) @@ -92,58 +95,24 @@ const getPublishedPort = (containerId: string) => { return hostPort } -const ensureSchema = async (databaseUrl: string) => { - const dbLayer = Database.layer({ - url: Redacted.make(databaseUrl), - ssl: false, - }) +const TableRow = Schema.Struct({ tablename: Schema.String }) - const existingTables = await Effect.runPromise( +const listTables = (dbLayer: Layer.Layer) => + Effect.runPromise( Effect.gen(function* () { const db = yield* Database.Database - return yield* db.execute((client) => - client.$client.unsafe( - "select tablename from pg_tables where schemaname = 'public' order by tablename", - ), + return yield* db.query( + Database.sql`select tablename from pg_tables where schemaname = 'public' order by tablename`, + TableRow, ) }).pipe(Effect.provide(dbLayer), Effect.scoped), ) - const tableSet = new Set( - existingTables.flatMap((row) => - typeof row === "object" && row !== null && "tablename" in row - ? [String((row as unknown as { tablename: unknown }).tablename)] - : [], - ), - ) - - const missingTables = REQUIRED_TABLES.filter((table) => !tableSet.has(table)) - if (missingTables.length === 0) { - return - } +const ensureSchema = async (databaseUrl: string, dbLayer: Layer.Layer) => { + await Effect.runPromise(migrateUrl(databaseUrl)) - runDbPush(databaseUrl) - - const recheckedTables = await Effect.runPromise( - Effect.gen(function* () { - const db = yield* Database.Database - return yield* db.execute((client) => - client.$client.unsafe( - "select tablename from pg_tables where schemaname = 'public' order by tablename", - ), - ) - }).pipe(Effect.provide(dbLayer), Effect.scoped), - ) - - const recheckedSet = new Set( - recheckedTables.flatMap((row) => - typeof row === "object" && row !== null && "tablename" in row - ? [String((row as unknown as { tablename: unknown }).tablename)] - : [], - ), - ) - - const stillMissing = REQUIRED_TABLES.filter((table) => !recheckedSet.has(table)) + const tableSet = new Set((await listTables(dbLayer)).map((row) => row.tablename)) + const stillMissing = REQUIRED_TABLES.filter((table) => !tableSet.has(table)) if (stillMissing.length > 0) { throw new Error(`Chat sync test schema is incomplete: missing ${stillMissing.join(", ")}`) } @@ -170,13 +139,12 @@ export const createChatSyncDbHarness = async (): Promise => { const hostPort = getPublishedPort(containerId) const databaseUrl = `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${hostPort}/${POSTGRES_DB}?sslmode=disable` - runDbPush(databaseUrl) - await ensureSchema(databaseUrl) - const dbLayer = Database.layer({ url: Redacted.make(databaseUrl), ssl: false, - }) + }).pipe(Layer.orDie) + + await ensureSchema(databaseUrl, dbLayer) const run = (effect: Effect.Effect) => Effect.runPromise( @@ -187,7 +155,7 @@ export const createChatSyncDbHarness = async (): Promise => { run( Effect.gen(function* () { const db = yield* Database.Database - yield* db.execute((client) => client.$client.unsafe(TRUNCATE_SQL)) + yield* db.execute(Database.sql.raw(TRUNCATE_SQL)) }), ) diff --git a/apps/backend/src/test/message-outbox-repo.test.ts b/apps/backend/src/test/message-outbox-repo.test.ts index 8b67609a5..6d5bfa2d9 100644 --- a/apps/backend/src/test/message-outbox-repo.test.ts +++ b/apps/backend/src/test/message-outbox-repo.test.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto" import { MessageOutboxRepo } from "@hazel/backend-core" -import { Database, eq, schema } from "@hazel/db" +import { Database, PG, schema } from "@hazel/db" import type { ChannelId, MessageId, MessageReactionId, UserId } from "@hazel/schema" import { Effect } from "effect" import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" @@ -108,13 +108,12 @@ describe("MessageOutboxRepo", () => { }, }) - yield* db.execute((client) => - client - .update(schema.messageOutboxEventsTable) + yield* db.run( + PG.update(schema.messageOutboxEventsTable) .set({ availableAt: new Date(Date.now() + 60_000), }) - .where(eq(schema.messageOutboxEventsTable.id, futureEvent.id)), + .where(($) => [$.id.eq(futureEvent.id)]), ) yield* repo.insert({ @@ -164,15 +163,14 @@ describe("MessageOutboxRepo", () => { }, }) - yield* db.execute((client) => - client - .update(schema.messageOutboxEventsTable) + yield* db.run( + PG.update(schema.messageOutboxEventsTable) .set({ status: "processing", lockedAt: new Date(Date.now() - 180_000), lockedBy: "old-worker", }) - .where(eq(schema.messageOutboxEventsTable.id, event.id)), + .where(($) => [$.id.eq(event.id)]), ) return yield* repo.claimNextBatch({ From 89c4875ec7feba1ac97b6436460e7485c726fd56 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 13 Oct 2026 00:44:46 +0200 Subject: [PATCH 7/8] chore(deps): drop drizzle from the lockfile Co-Authored-By: Claude Opus 5.5 --- bun.lock | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bun.lock b/bun.lock index 9df8408fb..afc4613da 100644 --- a/bun.lock +++ b/bun.lock @@ -59,7 +59,6 @@ "alchemy": "catalog:alchemy", "aws4fetch": "^1.0.20", "dfx": "^1.1.0", - "drizzle-orm": "^0.45.1", "effect": "catalog:effect", "jose": "^6.1.3", "pg": "^8.16.3", @@ -69,7 +68,6 @@ "@cloudflare/workers-types": "catalog:alchemy", "@testcontainers/postgresql": "^10.18.0", "@types/bun": "1.3.9", - "drizzle-kit": "^0.31.8", "typescript": "^5.9.3", }, }, @@ -105,7 +103,6 @@ "@hazel/effect-bun": "workspace:*", "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", - "drizzle-orm": "^0.45.1", "effect": "catalog:effect", }, "devDependencies": { @@ -177,7 +174,6 @@ "@hazel/infra": "workspace:*", "@hazel/schema": "workspace:*", "alchemy": "catalog:alchemy", - "drizzle-orm": "^0.45.1", "effect": "catalog:effect", }, "devDependencies": { From 611a36c24d2736e8e62330e01e69e3178aad41b1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 13 Oct 2026 01:15:21 +0200 Subject: [PATCH 8/8] fix(db): review fixes for the effect-orm migration - Outbox claim locks the candidate ids (FOR UPDATE SKIP LOCKED), then updates exactly those, in one transaction: an UPDATE ... WHERE id IN (subquery ... LIMIT) may re-evaluate its subquery and over-claim. - Unique-conflict fallbacks (connect conversations, mounts, participants) run their insert in a savepoint, so the recovery lookup does not hit an aborted transaction. - The initial migration creates the seven unique keys drizzle-kit push made constraints as UNIQUE constraints: a fresh database now pg_dumps identically to a pushed one. - Id columns are type-only brands again, as drizzle's $type was, so one stored id outside its schema's checks cannot fail a whole read. - Database.layer probes the database at startup with the old retry schedule for every caller, replacing two copies of the probe; the transaction span is back; the redundant Reactivity layer is gone. - db:migrate and the effect-orm driver require DATABASE_URL instead of falling back to localhost. Co-Authored-By: Claude Opus 5.5 --- .../src/rpc/handlers/connect-shares.ts | 23 ++++--- .../connect-conversation-service.test.ts | 10 +++ .../services/connect-conversation-service.ts | 47 +++++++------ apps/backend/src/services/database.ts | 45 +++--------- apps/cluster/src/index.ts | 36 ++-------- .../repositories/connect-participant-repo.ts | 3 +- .../src/repositories/message-outbox-repo.ts | 68 +++++++++++-------- packages/db/effect-orm.config.ts | 2 +- .../migration.sql | 14 ++-- packages/db/scripts/migrate.ts | 6 +- packages/db/scripts/render-ddl.ts | 26 ++++++- packages/db/src/services/database.test.ts | 17 +++++ packages/db/src/services/database.ts | 57 +++++++++++++--- packages/db/src/tables/attachments.ts | 12 ++-- packages/db/src/tables/bot-commands.ts | 6 +- packages/db/src/tables/bot-installations.ts | 10 +-- packages/db/src/tables/bots.ts | 8 +-- packages/db/src/tables/channel-access.ts | 8 +-- packages/db/src/tables/channel-sections.ts | 6 +- packages/db/src/tables/channel-webhooks.ts | 12 ++-- packages/db/src/tables/channels.ts | 18 ++--- packages/db/src/tables/chat-sync.ts | 32 ++++----- packages/db/src/tables/columns.ts | 8 +++ packages/db/src/tables/connect.ts | 44 ++++++------ packages/db/src/tables/custom-emojis.ts | 8 +-- .../db/src/tables/github-subscriptions.ts | 10 +-- packages/db/src/tables/index.ts | 2 +- .../db/src/tables/integration-connections.ts | 10 +-- .../db/src/tables/integration-requests.ts | 8 +-- packages/db/src/tables/integration-tokens.ts | 6 +- packages/db/src/tables/message-outbox.ts | 6 +- packages/db/src/tables/messages.ts | 24 +++---- packages/db/src/tables/notifications.ts | 6 +- packages/db/src/tables/organizations.ts | 12 ++-- packages/db/src/tables/pinned-messages.ts | 10 +-- packages/db/src/tables/rss-subscriptions.ts | 14 ++-- packages/db/src/tables/typing-indicators.ts | 8 +-- .../db/src/tables/user-presence-status.ts | 8 +-- packages/db/src/tables/users.ts | 4 +- 39 files changed, 360 insertions(+), 294 deletions(-) diff --git a/apps/backend/src/rpc/handlers/connect-shares.ts b/apps/backend/src/rpc/handlers/connect-shares.ts index dc9ef1a42..b4359aec6 100644 --- a/apps/backend/src/rpc/handlers/connect-shares.ts +++ b/apps/backend/src/rpc/handlers/connect-shares.ts @@ -392,16 +392,19 @@ export const ConnectShareRpcLive = ConnectShareRpcs.toLayer( joinedAt: new Date(), deletedAt: null, }) - yield* connectConversationChannelRepo - .insert({ - conversationId: invite.conversationId, - organizationId: guestOrganizationId, - channelId: guestChannel.id, - role: "guest", - allowGuestMemberAdds: invite.allowGuestMemberAdds, - isActive: true, - deletedAt: null, - }) + // A savepoint, so a unique conflict leaves the outer transaction usable for the lookup below. + yield* db + .transaction( + connectConversationChannelRepo.insert({ + conversationId: invite.conversationId, + organizationId: guestOrganizationId, + channelId: guestChannel.id, + role: "guest", + allowGuestMemberAdds: invite.allowGuestMemberAdds, + isActive: true, + deletedAt: null, + }), + ) .pipe( Effect.catchTag("DatabaseError", (error) => remapGuestMountInsertConflict({ diff --git a/apps/backend/src/services/connect-conversation-service.test.ts b/apps/backend/src/services/connect-conversation-service.test.ts index 3d6b22ea2..422ab8674 100644 --- a/apps/backend/src/services/connect-conversation-service.test.ts +++ b/apps/backend/src/services/connect-conversation-service.test.ts @@ -22,6 +22,12 @@ import { ChannelAccessSyncService } from "./channel-access-sync" import { ConnectConversationService } from "./connect-conversation-service" import { OrgResolver } from "./org-resolver" +/** The service only opens savepoints itself; the repositories it calls are faked. */ +const makeDatabaseLayer = () => + Layer.succeed(Database.Database, { + transaction: (effect: Effect.Effect) => effect, + } as unknown as Database.DatabaseShape) + const HOST_ORG_ID = "00000000-0000-4000-8000-000000000101" as OrganizationId const GUEST_ORG_ID = "00000000-0000-4000-8000-000000000102" as OrganizationId const OTHER_GUEST_ORG_ID = "00000000-0000-4000-8000-000000000103" as OrganizationId @@ -199,6 +205,7 @@ const makeServiceLayer = (params: { Layer.provide(makeMessageReactionRepoLayer()), Layer.provide(makeChannelAccessSyncLayer(params.syncedChannels)), Layer.provide(makeOrgResolverLayer()), + Layer.provide(makeDatabaseLayer()), ) const useService = (fn: (service: ConnectConversationServiceShape) => Effect.Effect) => @@ -318,6 +325,7 @@ describe("ConnectConversationService", () => { ), Layer.provide(makeChannelAccessSyncLayer([])), Layer.provide(makeOrgResolverLayer()), + Layer.provide(makeDatabaseLayer()), ) const mount = await Effect.runPromise( @@ -432,6 +440,7 @@ describe("ConnectConversationService", () => { ), Layer.provide(makeChannelAccessSyncLayer([])), Layer.provide(makeOrgResolverLayer()), + Layer.provide(makeDatabaseLayer()), ) await Effect.runPromise( @@ -518,6 +527,7 @@ describe("ConnectConversationService", () => { Layer.provide(makeMessageReactionRepoLayer()), Layer.provide(makeChannelAccessSyncLayer([])), Layer.provide(makeOrgResolverLayer()), + Layer.provide(makeDatabaseLayer()), ) await Effect.runPromise( diff --git a/apps/backend/src/services/connect-conversation-service.ts b/apps/backend/src/services/connect-conversation-service.ts index 494490692..1e7c8cda7 100644 --- a/apps/backend/src/services/connect-conversation-service.ts +++ b/apps/backend/src/services/connect-conversation-service.ts @@ -6,7 +6,7 @@ import { MessageReactionRepo, MessageRepo, } from "@hazel/backend-core" -import type { DatabaseError } from "@hazel/db" +import { Database, type DatabaseError } from "@hazel/db" import { InternalServerError } from "@hazel/domain" import type { ChannelId, ConnectConversationId, OrganizationId, UserId } from "@hazel/schema" import { Context, Effect, Layer, Option } from "effect" @@ -25,6 +25,7 @@ export class ConnectConversationService extends Context.Service error.type === "unique_violation" && error.constraint === constraintName @@ -80,15 +81,19 @@ export class ConnectConversationService extends Context.Service results[0]!), Effect.catchTag("DatabaseError", (error) => { @@ -99,16 +104,18 @@ export class ConnectConversationService extends Context.Service results[0]!), Effect.catchTag("DatabaseError", (error) => { diff --git a/apps/backend/src/services/database.ts b/apps/backend/src/services/database.ts index 3e0fbded3..de3419d68 100644 --- a/apps/backend/src/services/database.ts +++ b/apps/backend/src/services/database.ts @@ -1,42 +1,13 @@ import { Database } from "@hazel/db" -import { Effect, Layer, Schedule } from "effect" +import { Effect, Layer } from "effect" import { EnvVars } from "../lib/env-vars" -/** - * Checks the database is reachable at startup, retrying for a while before giving up: the pool - * itself connects lazily, so without this a bad DATABASE_URL would only surface on the first query. - */ -const ConnectionCheck = Layer.effectDiscard( +export const DatabaseLive = Layer.unwrap( Effect.gen(function* () { - const db = yield* Database.Database - yield* db.execute(Database.sql`SELECT 1`).pipe( - Effect.retry( - Schedule.jittered(Schedule.spaced("1.25 seconds")).pipe( - (schedule) => Schedule.max([schedule, Schedule.recurs(10)]), - Schedule.tap(({ attempt }) => - Effect.logWarning( - `[Database client]: Connection to the database failed. Retrying (attempt ${attempt}).`, - ), - ), - ), - ), - Effect.tap(() => Effect.logInfo("[Database client]: Connection to the database established.")), - Effect.orDie, - ) + const envVars = yield* EnvVars + return Database.layer({ + url: envVars.DATABASE_URL, + ssl: !envVars.IS_DEV, + }) }), -) - -export const DatabaseLive = ConnectionCheck.pipe( - Layer.provideMerge( - Layer.unwrap( - Effect.gen(function* () { - const envVars = yield* EnvVars - return Database.layer({ - url: envVars.DATABASE_URL, - ssl: !envVars.IS_DEV, - }) - }), - ).pipe(Layer.orDie), - ), - Layer.provide(EnvVars.layer), -) +).pipe(Layer.provide(EnvVars.layer)) diff --git a/apps/cluster/src/index.ts b/apps/cluster/src/index.ts index 524879411..778fd031f 100644 --- a/apps/cluster/src/index.ts +++ b/apps/cluster/src/index.ts @@ -7,7 +7,7 @@ import { WorkflowProxyServer } from "effect/workflow" import { Database } from "@hazel/db" import { Cluster } from "@hazel/domain" import { createTracingLayer } from "@hazel/effect-bun/Telemetry" -import { Config, ConfigProvider, Effect, Layer, Logger, Redacted, Schedule } from "effect" +import { Config, ConfigProvider, Effect, Layer, Logger, Redacted } from "effect" import { PresenceCleanupCronLayer } from "./cron/presence-cleanup-cron.ts" import { StatusExpirationCronLayer } from "./cron/status-expiration-cron.ts" import { TypingIndicatorCleanupCronLayer } from "./cron/typing-indicator-cleanup-cron.ts" @@ -35,35 +35,11 @@ const WorkflowEngineLayer = ClusterWorkflowEngine.layer.pipe( ), ) -// Database layer (uses same credentials as PgClient). The pool connects lazily, so probe it once at -// startup: retry while the database is unreachable, then die, as the previous client did. -const DatabaseLayer = Layer.effectDiscard( - Effect.gen(function* () { - const db = yield* Database.Database - yield* db.execute(Database.sql`SELECT 1`).pipe( - Effect.retry( - Schedule.jittered(Schedule.spaced("1.25 seconds")).pipe( - (schedule) => Schedule.max([schedule, Schedule.recurs(10)]), - Schedule.tap(({ attempt }) => - Effect.logWarning( - `[Database client]: Connection to the database failed. Retrying (attempt ${attempt}).`, - ), - ), - ), - ), - Effect.tap(() => Effect.logInfo("[Database client]: Connection to the database established.")), - Effect.orDie, - ) - }), -).pipe( - Layer.provideMerge( - Database.layer({ - url: Redacted.make(process.env.DATABASE_URL as string)!, - ssl: !process.env.IS_DEV, - }), - ), - Layer.orDie, -) +// Database layer (uses same credentials as PgClient) +const DatabaseLayer = Database.layer({ + url: Redacted.make(process.env.DATABASE_URL as string)!, + ssl: !process.env.IS_DEV, +}) // OpenTelemetry tracing layer const TracerLive = createTracingLayer("cluster") diff --git a/packages/backend-core/src/repositories/connect-participant-repo.ts b/packages/backend-core/src/repositories/connect-participant-repo.ts index 157a36ee8..8e4dae384 100644 --- a/packages/backend-core/src/repositories/connect-participant-repo.ts +++ b/packages/backend-core/src/repositories/connect-participant-repo.ts @@ -42,7 +42,8 @@ export class ConnectParticipantRepo extends Context.Service, ) => - baseRepo.insert(data).pipe( + // A savepoint, so a unique conflict leaves an enclosing transaction usable for the lookup. + db.transaction(baseRepo.insert(data)).pipe( Effect.map((results) => results[0]!), Effect.catchTag("DatabaseError", (error) => { if ( diff --git a/packages/backend-core/src/repositories/message-outbox-repo.ts b/packages/backend-core/src/repositories/message-outbox-repo.ts index 6fd016e35..bbbd074f9 100644 --- a/packages/backend-core/src/repositories/message-outbox-repo.ts +++ b/packages/backend-core/src/repositories/message-outbox-repo.ts @@ -145,36 +145,46 @@ export class MessageOutboxRepo extends Context.Service()("Mes * went stale. Candidates are locked with FOR UPDATE SKIP LOCKED so concurrent workers never * claim the same event. */ + /** + * Claims up to `limit` due events. The candidates are locked first (`FOR UPDATE SKIP LOCKED`) + * and exactly those ids are updated, in one transaction: an `UPDATE ... WHERE id IN (subquery + * ... LIMIT)` may re-evaluate its subquery and claim more rows, or rows another worker locked. + */ const claimNextBatch = (params: ClaimNextBatchParams) => - Effect.gen(function* () { - const staleBefore = new Date(Date.now() - params.lockTimeoutMs) - const lockedAt = new Date() - - const candidates = PG.from(schema.messageOutboxEventsTable) - .select("id") - .where(($) => [ - PG.or( - $.status.eq("pending").and($.availableAt.lte(schema.now())), - $.status.eq("processing").and($.lockedAt.lt(staleBefore)), - ), - ]) - .orderBy(($) => [[$.sequence, "asc"]]) - .limit(params.limit) - .forUpdate({ skipLocked: true }) - - const rows = yield* db.run( - PG.update(schema.messageOutboxEventsTable) - .set({ - status: "processing", - lockedAt, - lockedBy: params.workerId, - }) - .where(($) => [PG.inSubquery($.id, candidates)]) - .returning(), - ) - - return rows.slice().sort((left, right) => left.sequence - right.sequence) - }) + db.transaction( + Effect.gen(function* () { + const staleBefore = new Date(Date.now() - params.lockTimeoutMs) + const lockedAt = new Date() + + const candidates = yield* db.run( + PG.from(schema.messageOutboxEventsTable) + .select("id") + .where(($) => [ + PG.or( + $.status.eq("pending").and($.availableAt.lte(schema.now())), + $.status.eq("processing").and($.lockedAt.lt(staleBefore)), + ), + ]) + .orderBy(($) => [[$.sequence, "asc"]]) + .limit(params.limit) + .forUpdate({ skipLocked: true }), + ) + if (candidates.length === 0) return [] + + const rows = yield* db.run( + PG.update(schema.messageOutboxEventsTable) + .set({ + status: "processing", + lockedAt, + lockedBy: params.workerId, + }) + .where(($) => [$.id.in_(...candidates.map((candidate) => candidate.id))]) + .returning(), + ) + + return rows.slice().sort((left, right) => left.sequence - right.sequence) + }), + ) const markProcessed = (id: MessageOutboxEventId) => db.first( diff --git a/packages/db/effect-orm.config.ts b/packages/db/effect-orm.config.ts index 5a9712c58..ca03f23de 100644 --- a/packages/db/effect-orm.config.ts +++ b/packages/db/effect-orm.config.ts @@ -1,7 +1,7 @@ import * as PgClient from "@effect/sql-pg/PgClient" import { defineConfig } from "@maple-dev/effect-orm/kit" import * as Migrate from "@maple-dev/effect-orm/migrate" -import { Layer, Redacted } from "effect" +import { Effect, Layer, Redacted } from "effect" export default defineConfig({ dialect: "postgres", diff --git a/packages/db/migrations/20261012000000_initial_schema/migration.sql b/packages/db/migrations/20261012000000_initial_schema/migration.sql index 7ad78a528..b580d9399 100644 --- a/packages/db/migrations/20261012000000_initial_schema/migration.sql +++ b/packages/db/migrations/20261012000000_initial_schema/migration.sql @@ -561,7 +561,7 @@ CREATE INDEX IF NOT EXISTS "bots_deleted_at_idx" ON "bots" USING btree ("deleted --> statement-breakpoint CREATE INDEX IF NOT EXISTS "bots_is_public_idx" ON "bots" USING btree ("isPublic"); --> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "bots_userId_unique" ON "bots" USING btree ("userId"); +ALTER TABLE "bots" ADD CONSTRAINT "bots_userId_unique" UNIQUE ("userId"); --> statement-breakpoint CREATE INDEX IF NOT EXISTS "bots_user_id_idx" ON "bots" USING btree ("userId"); --> statement-breakpoint @@ -745,7 +745,7 @@ CREATE INDEX IF NOT EXISTS "int_tokens_expires_at_idx" ON "integration_tokens" U --> statement-breakpoint CREATE INDEX IF NOT EXISTS "int_tokens_key_version_idx" ON "integration_tokens" USING btree ("encryptionKeyVersion"); --> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "integration_tokens_connectionId_unique" ON "integration_tokens" USING btree ("connectionId"); +ALTER TABLE "integration_tokens" ADD CONSTRAINT "integration_tokens_connectionId_unique" UNIQUE ("connectionId"); --> statement-breakpoint CREATE INDEX IF NOT EXISTS "message_outbox_events_channel_sequence_idx" ON "message_outbox_events" USING btree ("channelId", "sequence"); --> statement-breakpoint @@ -765,7 +765,7 @@ CREATE INDEX IF NOT EXISTS "reactions_message_id_idx" ON "message_reactions" USI --> statement-breakpoint CREATE INDEX IF NOT EXISTS "reactions_message_user_emoji_idx" ON "message_reactions" USING btree ("messageId", "userId", "emoji"); --> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "reactions_message_user_emoji_unique" ON "message_reactions" USING btree ("messageId", "userId", "emoji"); +ALTER TABLE "message_reactions" ADD CONSTRAINT "reactions_message_user_emoji_unique" UNIQUE ("messageId", "userId", "emoji"); --> statement-breakpoint CREATE INDEX IF NOT EXISTS "reactions_user_id_idx" ON "message_reactions" USING btree ("userId"); --> statement-breakpoint @@ -795,7 +795,7 @@ CREATE INDEX IF NOT EXISTS "notifications_resource_idx" ON "notifications" USING --> statement-breakpoint CREATE INDEX IF NOT EXISTS "notifications_targeted_resource_idx" ON "notifications" USING btree ("targetedResourceId", "targetedResourceType"); --> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "org_members_org_user_unique" ON "organization_members" USING btree ("organizationId", "userId"); +ALTER TABLE "organization_members" ADD CONSTRAINT "org_members_org_user_unique" UNIQUE ("organizationId", "userId"); --> statement-breakpoint CREATE INDEX IF NOT EXISTS "org_members_organization_id_idx" ON "organization_members" USING btree ("organizationId"); --> statement-breakpoint @@ -807,7 +807,7 @@ CREATE INDEX IF NOT EXISTS "organizations_deleted_at_idx" ON "organizations" USI --> statement-breakpoint CREATE INDEX IF NOT EXISTS "organizations_slug_idx" ON "organizations" USING btree ("slug"); --> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "organizations_slug_unique" ON "organizations" USING btree ("slug"); +ALTER TABLE "organizations" ADD CONSTRAINT "organizations_slug_unique" UNIQUE ("slug"); --> statement-breakpoint CREATE INDEX IF NOT EXISTS "pinned_messages_channel_id_idx" ON "pinned_messages" USING btree ("channelId"); --> statement-breakpoint @@ -839,7 +839,7 @@ CREATE INDEX IF NOT EXISTS "user_presence_status_active_channel_idx" ON "user_pr --> statement-breakpoint CREATE INDEX IF NOT EXISTS "user_presence_status_last_seen_idx" ON "user_presence_status" USING btree ("lastSeenAt"); --> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "user_presence_status_userId_unique" ON "user_presence_status" USING btree ("userId"); +ALTER TABLE "user_presence_status" ADD CONSTRAINT "user_presence_status_userId_unique" UNIQUE ("userId"); --> statement-breakpoint CREATE INDEX IF NOT EXISTS "user_presence_status_user_id_idx" ON "user_presence_status" USING btree ("userId"); --> statement-breakpoint @@ -847,7 +847,7 @@ CREATE INDEX IF NOT EXISTS "users_deleted_at_idx" ON "users" USING btree ("delet --> statement-breakpoint CREATE INDEX IF NOT EXISTS "users_email_idx" ON "users" USING btree ("email"); --> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "users_externalId_unique" ON "users" USING btree ("externalId"); +ALTER TABLE "users" ADD CONSTRAINT "users_externalId_unique" UNIQUE ("externalId"); --> statement-breakpoint CREATE INDEX IF NOT EXISTS "users_external_id_idx" ON "users" USING btree ("externalId"); --> statement-breakpoint diff --git a/packages/db/scripts/migrate.ts b/packages/db/scripts/migrate.ts index 04d266ffc..197a6fc19 100644 --- a/packages/db/scripts/migrate.ts +++ b/packages/db/scripts/migrate.ts @@ -9,7 +9,11 @@ import { Effect } from "effect" import { migrateUrl } from "../src/migrate" -const url = process.env.DATABASE_URL ?? "postgresql://user:password@localhost:5432/app" +const url = process.env.DATABASE_URL +if (!url) { + console.error("DATABASE_URL is not set: refusing to guess which database to migrate.") + process.exit(1) +} const program = migrateUrl(url).pipe( Effect.tap(({ adopted, applied }) => diff --git a/packages/db/scripts/render-ddl.ts b/packages/db/scripts/render-ddl.ts index 35bc4297f..d46076a94 100644 --- a/packages/db/scripts/render-ddl.ts +++ b/packages/db/scripts/render-ddl.ts @@ -9,12 +9,34 @@ const enums = exported.filter( (value): value is { enumName: string; enumValues: ReadonlyArray } => typeof value === "object" && value !== null && "enumName" in value, ) +/** + * Unique keys `drizzle-kit push` created as UNIQUE constraints. effect-orm models them as unique + * indexes; the initial migration creates the constraints, so a fresh database matches one push built. + */ +const UNIQUE_CONSTRAINTS = new Set([ + "bots_userId_unique", + "integration_tokens_connectionId_unique", + "org_members_org_user_unique", + "organizations_slug_unique", + "reactions_message_user_emoji_unique", + "user_presence_status_userId_unique", + "users_externalId_unique", +]) + +const asConstraint = (statement: string): string => { + const match = /^CREATE UNIQUE INDEX IF NOT EXISTS "([^"]+)" ON "([^"]+)" USING btree \((.+)\);$/.exec( + statement, + ) + if (match === null || !UNIQUE_CONSTRAINTS.has(match[1]!)) return statement + return `ALTER TABLE "${match[2]}" ADD CONSTRAINT "${match[1]}" UNIQUE (${match[3]});` +} + const statements = [ ...enums.map( (e) => `CREATE TYPE ${e.enumName} AS ENUM (${e.enumValues.map((v) => `'${v}'`).join(", ")});`, ), - ...S.renderPgSchema(Effect.runSync(S.pgEntitiesOf(exported.filter(S.isSchemaObject)))).map( - (s) => `${s};`, + ...S.renderPgSchema(Effect.runSync(S.pgEntitiesOf(exported.filter(S.isSchemaObject)))).map((s) => + asConstraint(`${s};`), ), ] console.log(statements.join("\n--> statement-breakpoint\n")) diff --git a/packages/db/src/services/database.test.ts b/packages/db/src/services/database.test.ts index c74cca080..5525a5e41 100644 --- a/packages/db/src/services/database.test.ts +++ b/packages/db/src/services/database.test.ts @@ -100,6 +100,23 @@ describe("Database.transaction", () => { ) expect(counts).toEqual([1, 0]) }) + it("keeps the outer transaction usable after a conflict inside a savepoint", async () => { + const id = `savepoint-${Date.now()}` + const result = await run( + Effect.gen(function* () { + const db = yield* Database + yield* insertRow(id) + return yield* db.transaction( + Effect.gen(function* () { + // The pattern the unique-conflict fallbacks use: the insert in a savepoint, the lookup after. + const conflict = yield* db.transaction(insertRow(id)).pipe(Effect.flip) + return { conflict: conflict.type, count: yield* countRows(id) } + }), + ) + }), + ) + expect(result).toEqual({ conflict: "unique_violation", count: 1 }) + }) }) describe("DatabaseError", () => { diff --git a/packages/db/src/services/database.ts b/packages/db/src/services/database.ts index 06516e80c..2c7a4b8c3 100644 --- a/packages/db/src/services/database.ts +++ b/packages/db/src/services/database.ts @@ -12,9 +12,20 @@ */ import * as PgClient from "@effect/sql-pg/PgClient" import * as Orm from "@maple-dev/effect-orm/database" +import { sql } from "@maple-dev/effect-orm/database" import { CompiledQueryDecodeError, postgresDialect, QueryBuilderError } from "@maple-dev/effect-orm/postgres" -import { Cause, Context, Duration, Effect, Layer, Option, Redacted, Schema, type Scope } from "effect" -import * as Reactivity from "effect/reactivity/Reactivity" +import { + Cause, + Context, + Duration, + Effect, + Layer, + Option, + Redacted, + Schedule, + Schema, + type Scope, +} from "effect" import * as SqlClient from "effect/sql/SqlClient" import { SqlError } from "effect/sql/SqlError" @@ -200,7 +211,9 @@ const makeShape = (getOrm: Effect.Effect): DatabaseShape => { )) as DatabaseShape["query"], execute: (statement) => Effect.flatMap(getOrm, (orm) => absorb(orm.execute(statement))), transaction: (body, options) => - Effect.flatMap(getOrm, (orm) => absorb(orm.transaction(body, options))) as never, + Effect.flatMap(getOrm, (orm) => absorb(orm.transaction(body, options))).pipe( + Effect.withSpan("Database.transaction"), + ) as never, } } @@ -216,14 +229,37 @@ export type Config = { maxConnections?: number } -/** A long-lived pool, for long-running processes (Bun servers, the cluster, scripts). */ -export const layer = (config: Config): Layer.Layer => +/** + * Checks the database is reachable, retrying for a while before giving up: the pool connects + * lazily, so without this a bad URL would only surface on the first query. + */ +const startupCheck = (shape: DatabaseShape) => + shape.execute(sql`SELECT 1`).pipe( + Effect.retry( + Schedule.jittered(Schedule.spaced("1.25 seconds")).pipe( + (schedule) => Schedule.max([schedule, Schedule.recurs(10)]), + Schedule.tap(({ attempt }) => + Effect.logWarning( + `[Database client]: Connection to the database failed. Retrying (attempt ${attempt}).`, + ), + ), + ), + ), + Effect.tap(() => Effect.logInfo("[Database client]: Connection to the database established.")), + Effect.orDie, + ) + +/** + * A long-lived pool, for long-running processes (Bun servers, the cluster, scripts). Probes the + * database at startup and dies if it stays unreachable. + */ +export const layer = (config: Config): Layer.Layer => Layer.effect( Database, Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient - const orm = fromSqlClient(sql) - return makeShape(Effect.succeed(orm)) + const shape = makeShape(Effect.succeed(fromSqlClient(yield* SqlClient.SqlClient))) + yield* startupCheck(shape) + return shape }), ).pipe( Layer.provide( @@ -236,6 +272,7 @@ export const layer = (config: Config): Layer.Layer => : { maxConnections: config.maxConnections }), }), ), + Layer.orDie, ) /** @@ -268,7 +305,7 @@ export const makeRequestConnection = ( connectTimeout: Duration.seconds(10), prepare: false, }), - ).pipe(Layer.provide(Reactivity.layer)), + ), ) return { orm: fromSqlClient(Context.get(services, SqlClient.SqlClient)) } }) @@ -300,4 +337,4 @@ export const requireTransaction = Orm.requireTransaction /** The open transaction; `transaction` provides it, `requireTransaction` requires it. */ export const Transaction = Orm.Transaction -export { sql } from "@maple-dev/effect-orm/database" +export { sql } diff --git a/packages/db/src/tables/attachments.ts b/packages/db/src/tables/attachments.ts index 82597986f..8e44cb9da 100644 --- a/packages/db/src/tables/attachments.ts +++ b/packages/db/src/tables/attachments.ts @@ -1,19 +1,19 @@ import { AttachmentId, ChannelId, MessageId, OrganizationId, UserId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { pgEnum, timestamptz, varchar } from "./columns" +import { now, pgEnum, timestamptz, uuid, varchar } from "./columns" export const attachmentStatusEnum = pgEnum("attachment_status", ["uploading", "complete", "failed"]) export const attachmentsTable = PG.table("attachments", { columns: { - id: PG.column(PG.brand(PG.uuid, AttachmentId), { defaultExpr: "gen_random_uuid()" }), - organizationId: PG.brand(PG.uuid, OrganizationId), - channelId: PG.nullable(PG.brand(PG.uuid, ChannelId)), - messageId: PG.nullable(PG.brand(PG.uuid, MessageId)), + id: PG.column(uuid(AttachmentId), { defaultExpr: "gen_random_uuid()" }), + organizationId: uuid(OrganizationId), + channelId: PG.nullable(uuid(ChannelId)), + messageId: PG.nullable(uuid(MessageId)), fileName: varchar(255), fileSize: PG.int4, externalUrl: PG.nullable(varchar(2048)), - uploadedBy: PG.brand(PG.uuid, UserId), + uploadedBy: uuid(UserId), status: PG.column(attachmentStatusEnum, { default: "uploading" }), uploadedAt: PG.column(timestamptz, { defaultExpr: "now()" }), deletedAt: PG.nullable(timestamptz), diff --git a/packages/db/src/tables/bot-commands.ts b/packages/db/src/tables/bot-commands.ts index eeba08388..b2d962342 100644 --- a/packages/db/src/tables/bot-commands.ts +++ b/packages/db/src/tables/bot-commands.ts @@ -1,6 +1,6 @@ import { BotCommandId, BotId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { jsonb, timestamptz, varchar } from "./columns" +import { jsonb, now, timestamptz, uuid, varchar } from "./columns" /** * Argument definition for a bot command @@ -18,8 +18,8 @@ export interface BotCommandArgument { */ export const botCommandsTable = PG.table("bot_commands", { columns: { - id: PG.column(PG.brand(PG.uuid, BotCommandId), { defaultExpr: "gen_random_uuid()" }), - botId: PG.brand(PG.uuid, BotId), + id: PG.column(uuid(BotCommandId), { defaultExpr: "gen_random_uuid()" }), + botId: uuid(BotId), name: varchar(50), description: PG.text, arguments: PG.column(PG.nullable(jsonb()), { default: [] }), diff --git a/packages/db/src/tables/bot-installations.ts b/packages/db/src/tables/bot-installations.ts index 49dad1077..2c9f2e81a 100644 --- a/packages/db/src/tables/bot-installations.ts +++ b/packages/db/src/tables/bot-installations.ts @@ -1,13 +1,13 @@ import { BotId, BotInstallationId, OrganizationId, UserId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { timestamptz } from "./columns" +import { now, timestamptz, uuid } from "./columns" export const botInstallationsTable = PG.table("bot_installations", { columns: { - id: PG.column(PG.brand(PG.uuid, BotInstallationId), { defaultExpr: "gen_random_uuid()" }), - botId: PG.brand(PG.uuid, BotId), - organizationId: PG.brand(PG.uuid, OrganizationId), - installedBy: PG.brand(PG.uuid, UserId), + id: PG.column(uuid(BotInstallationId), { defaultExpr: "gen_random_uuid()" }), + botId: uuid(BotId), + organizationId: uuid(OrganizationId), + installedBy: uuid(UserId), installedAt: PG.column(timestamptz, { defaultExpr: "now()" }), }, primaryKey: ["id"], diff --git a/packages/db/src/tables/bots.ts b/packages/db/src/tables/bots.ts index 4bbef8819..c46df5f18 100644 --- a/packages/db/src/tables/bots.ts +++ b/packages/db/src/tables/bots.ts @@ -1,15 +1,15 @@ import { BotId, UserId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { jsonb, timestamptz, varchar } from "./columns" +import { jsonb, now, timestamptz, uuid, varchar } from "./columns" import type { IntegrationConnection } from "./integration-connections" type IntegrationProvider = IntegrationConnection["provider"] export const botsTable = PG.table("bots", { columns: { - id: PG.column(PG.brand(PG.uuid, BotId), { defaultExpr: "gen_random_uuid()" }), - userId: PG.brand(PG.uuid, UserId), - createdBy: PG.brand(PG.uuid, UserId), + id: PG.column(uuid(BotId), { defaultExpr: "gen_random_uuid()" }), + userId: uuid(UserId), + createdBy: uuid(UserId), name: varchar(100), description: PG.nullable(PG.text), webhookUrl: PG.nullable(PG.text), diff --git a/packages/db/src/tables/channel-access.ts b/packages/db/src/tables/channel-access.ts index 3f710bebd..a3dfb535d 100644 --- a/packages/db/src/tables/channel-access.ts +++ b/packages/db/src/tables/channel-access.ts @@ -1,12 +1,12 @@ import { ChannelId, OrganizationId, UserId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { timestamptz } from "./columns" +import { now, timestamptz, uuid } from "./columns" export const channelAccessTable = PG.table("channel_access", { columns: { - userId: PG.brand(PG.uuid, UserId), - channelId: PG.brand(PG.uuid, ChannelId), - organizationId: PG.brand(PG.uuid, OrganizationId), + userId: uuid(UserId), + channelId: uuid(ChannelId), + organizationId: uuid(OrganizationId), createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), }, diff --git a/packages/db/src/tables/channel-sections.ts b/packages/db/src/tables/channel-sections.ts index e96e093f3..39b805311 100644 --- a/packages/db/src/tables/channel-sections.ts +++ b/packages/db/src/tables/channel-sections.ts @@ -1,11 +1,11 @@ import { ChannelSectionId, OrganizationId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { timestamptz, varchar } from "./columns" +import { now, timestamptz, uuid, varchar } from "./columns" export const channelSectionsTable = PG.table("channel_sections", { columns: { - id: PG.column(PG.brand(PG.uuid, ChannelSectionId), { defaultExpr: "gen_random_uuid()" }), - organizationId: PG.brand(PG.uuid, OrganizationId), + id: PG.column(uuid(ChannelSectionId), { defaultExpr: "gen_random_uuid()" }), + organizationId: uuid(OrganizationId), name: varchar(255), order: PG.column(PG.int4, { default: 0 }), createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), diff --git a/packages/db/src/tables/channel-webhooks.ts b/packages/db/src/tables/channel-webhooks.ts index ab69c0a25..a719f488b 100644 --- a/packages/db/src/tables/channel-webhooks.ts +++ b/packages/db/src/tables/channel-webhooks.ts @@ -1,20 +1,20 @@ import { ChannelId, ChannelWebhookId, OrganizationId, UserId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { timestamptz, varchar } from "./columns" +import { now, timestamptz, uuid, varchar } from "./columns" export const channelWebhooksTable = PG.table("channel_webhooks", { columns: { - id: PG.column(PG.brand(PG.uuid, ChannelWebhookId), { defaultExpr: "gen_random_uuid()" }), - channelId: PG.brand(PG.uuid, ChannelId), - organizationId: PG.brand(PG.uuid, OrganizationId), - botUserId: PG.brand(PG.uuid, UserId), + id: PG.column(uuid(ChannelWebhookId), { defaultExpr: "gen_random_uuid()" }), + channelId: uuid(ChannelId), + organizationId: uuid(OrganizationId), + botUserId: uuid(UserId), name: varchar(100), description: PG.nullable(PG.text), avatarUrl: PG.nullable(PG.text), tokenHash: PG.text, tokenSuffix: varchar(4), isEnabled: PG.column(PG.bool, { default: true }), - createdBy: PG.brand(PG.uuid, UserId), + createdBy: uuid(UserId), lastUsedAt: PG.nullable(timestamptz), createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), diff --git a/packages/db/src/tables/channels.ts b/packages/db/src/tables/channels.ts index 3d733cfb0..2bf5f9ea5 100644 --- a/packages/db/src/tables/channels.ts +++ b/packages/db/src/tables/channels.ts @@ -8,19 +8,19 @@ import { UserId, } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { pgEnum, timestamptz, varchar } from "./columns" +import { now, pgEnum, timestamptz, uuid, varchar } from "./columns" export const channelTypeEnum = pgEnum("channel_type", ["public", "private", "thread", "direct", "single"]) export const channelMembersTable = PG.table("channel_members", { columns: { - id: PG.column(PG.brand(PG.uuid, ChannelMemberId), { defaultExpr: "gen_random_uuid()" }), - channelId: PG.brand(PG.uuid, ChannelId), - userId: PG.brand(PG.uuid, UserId), + id: PG.column(uuid(ChannelMemberId), { defaultExpr: "gen_random_uuid()" }), + channelId: uuid(ChannelId), + userId: uuid(UserId), isHidden: PG.column(PG.bool, { default: false }), isMuted: PG.column(PG.bool, { default: false }), isFavorite: PG.column(PG.bool, { default: false }), - lastSeenMessageId: PG.nullable(PG.brand(PG.uuid, MessageId)), + lastSeenMessageId: PG.nullable(uuid(MessageId)), notificationCount: PG.column(PG.int4, { default: 0 }), joinedAt: PG.column(timestamptz, { defaultExpr: "now()" }), createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), @@ -38,13 +38,13 @@ export type NewChannelMember = PG.InsertRowOf export const channelsTable = PG.table("channels", { columns: { - id: PG.column(PG.brand(PG.uuid, ChannelId), { defaultExpr: "gen_random_uuid()" }), + id: PG.column(uuid(ChannelId), { defaultExpr: "gen_random_uuid()" }), name: varchar(255), icon: PG.nullable(PG.brand(varchar(32), ChannelIcon)), type: channelTypeEnum, - organizationId: PG.brand(PG.uuid, OrganizationId), - parentChannelId: PG.nullable(PG.brand(PG.uuid, ChannelId)), - sectionId: PG.nullable(PG.brand(PG.uuid, ChannelSectionId)), + organizationId: uuid(OrganizationId), + parentChannelId: PG.nullable(uuid(ChannelId)), + sectionId: PG.nullable(uuid(ChannelSectionId)), createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), deletedAt: PG.nullable(timestamptz), diff --git a/packages/db/src/tables/chat-sync.ts b/packages/db/src/tables/chat-sync.ts index fa0802c96..3586afbda 100644 --- a/packages/db/src/tables/chat-sync.ts +++ b/packages/db/src/tables/chat-sync.ts @@ -10,7 +10,7 @@ import { UserId, } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { jsonb, pgEnum, timestamptz, varchar } from "./columns" +import { jsonb, now, pgEnum, timestamptz, uuid, varchar } from "./columns" export const chatSyncConnectionStatusEnum = pgEnum("chat_sync_connection_status", [ "active", @@ -32,9 +32,9 @@ export const chatSyncReceiptStatusEnum = pgEnum("chat_sync_receipt_status", [ export const chatSyncChannelLinksTable = PG.table("chat_sync_channel_links", { columns: { - id: PG.column(PG.brand(PG.uuid, SyncChannelLinkId), { defaultExpr: "gen_random_uuid()" }), - syncConnectionId: PG.brand(PG.uuid, SyncConnectionId), - hazelChannelId: PG.brand(PG.uuid, ChannelId), + id: PG.column(uuid(SyncChannelLinkId), { defaultExpr: "gen_random_uuid()" }), + syncConnectionId: uuid(SyncConnectionId), + hazelChannelId: uuid(ChannelId), externalChannelId: varchar(255), externalChannelName: PG.nullable(varchar(255)), direction: PG.column(chatSyncDirectionEnum, { default: "both" }), @@ -66,9 +66,9 @@ export type NewChatSyncChannelLink = PG.InsertRowOf>()), errorMessage: PG.nullable(PG.text), lastSyncedAt: PG.nullable(timestamptz), - createdBy: PG.brand(PG.uuid, UserId), + createdBy: uuid(UserId), createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), deletedAt: PG.nullable(timestamptz), @@ -104,9 +104,9 @@ export type NewChatSyncConnection = PG.InsertRowOf]> /** `now()`, typed as the `Date` the timestamp columns hold. */ export const now = () => PG.sql(timestamptz)`now()` + +/** + * A `uuid` column typed as a branded id. The brand is type-only, as drizzle's `$type()` was: + * rows are not re-validated against the id schema on read, so one stored id outside its schema's + * checks (a nil-like fixture id, say) cannot fail a whole result set. + */ +export const uuid = (_id: S): PG.PgType => + typed()(PG.uuid) diff --git a/packages/db/src/tables/connect.ts b/packages/db/src/tables/connect.ts index 173045186..c45ae2863 100644 --- a/packages/db/src/tables/connect.ts +++ b/packages/db/src/tables/connect.ts @@ -8,7 +8,7 @@ import { UserId, } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { jsonb, pgEnum, timestamptz, varchar } from "./columns" +import { jsonb, now, pgEnum, timestamptz, uuid, varchar } from "./columns" export const connectConversationStatusEnum = pgEnum("connect_conversation_status", ["active", "disconnected"]) export const connectConversationChannelRoleEnum = pgEnum("connect_conversation_channel_role", [ @@ -26,10 +26,10 @@ export const connectInviteTargetKindEnum = pgEnum("connect_invite_target_kind", export const connectConversationChannelsTable = PG.table("connect_conversation_channels", { columns: { - id: PG.column(PG.brand(PG.uuid, ConnectConversationChannelId), { defaultExpr: "gen_random_uuid()" }), - conversationId: PG.brand(PG.uuid, ConnectConversationId), - organizationId: PG.brand(PG.uuid, OrganizationId), - channelId: PG.brand(PG.uuid, ChannelId), + id: PG.column(uuid(ConnectConversationChannelId), { defaultExpr: "gen_random_uuid()" }), + conversationId: uuid(ConnectConversationId), + organizationId: uuid(OrganizationId), + channelId: uuid(ChannelId), role: connectConversationChannelRoleEnum, allowGuestMemberAdds: PG.column(PG.bool, { default: false }), isActive: PG.column(PG.bool, { default: true }), @@ -58,12 +58,12 @@ export type NewConnectConversationChannel = PG.InsertRowOf>()), - createdBy: PG.brand(PG.uuid, UserId), + createdBy: uuid(UserId), createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), deletedAt: PG.nullable(timestamptz), @@ -84,17 +84,17 @@ export type NewConnectConversation = PG.InsertRowOf export const connectParticipantsTable = PG.table("connect_participants", { columns: { - id: PG.column(PG.brand(PG.uuid, ConnectParticipantId), { defaultExpr: "gen_random_uuid()" }), - conversationId: PG.brand(PG.uuid, ConnectConversationId), - channelId: PG.brand(PG.uuid, ChannelId), - userId: PG.brand(PG.uuid, UserId), - homeOrganizationId: PG.brand(PG.uuid, OrganizationId), + id: PG.column(uuid(ConnectParticipantId), { defaultExpr: "gen_random_uuid()" }), + conversationId: uuid(ConnectConversationId), + channelId: uuid(ChannelId), + userId: uuid(UserId), + homeOrganizationId: uuid(OrganizationId), isExternal: PG.column(PG.bool, { default: false }), - addedBy: PG.nullable(PG.brand(PG.uuid, UserId)), + addedBy: PG.nullable(uuid(UserId)), createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), deletedAt: PG.nullable(timestamptz), diff --git a/packages/db/src/tables/custom-emojis.ts b/packages/db/src/tables/custom-emojis.ts index 0de00a9ce..876d64949 100644 --- a/packages/db/src/tables/custom-emojis.ts +++ b/packages/db/src/tables/custom-emojis.ts @@ -1,14 +1,14 @@ import { CustomEmojiId, OrganizationId, UserId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { timestamptz, varchar } from "./columns" +import { now, timestamptz, uuid, varchar } from "./columns" export const customEmojisTable = PG.table("custom_emojis", { columns: { - id: PG.column(PG.brand(PG.uuid, CustomEmojiId), { defaultExpr: "gen_random_uuid()" }), - organizationId: PG.brand(PG.uuid, OrganizationId), + id: PG.column(uuid(CustomEmojiId), { defaultExpr: "gen_random_uuid()" }), + organizationId: uuid(OrganizationId), name: varchar(64), imageUrl: PG.text, - createdBy: PG.brand(PG.uuid, UserId), + createdBy: uuid(UserId), createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), updatedAt: PG.nullable(timestamptz), deletedAt: PG.nullable(timestamptz), diff --git a/packages/db/src/tables/github-subscriptions.ts b/packages/db/src/tables/github-subscriptions.ts index 1c2307538..181407870 100644 --- a/packages/db/src/tables/github-subscriptions.ts +++ b/packages/db/src/tables/github-subscriptions.ts @@ -1,15 +1,15 @@ import type { GitHubSubscription as GitHubSubscriptionModel } from "@hazel/domain/models" import { ChannelId, GitHubSubscriptionId, OrganizationId, UserId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { jsonb, timestamptz, varchar } from "./columns" +import { jsonb, now, timestamptz, uuid, varchar } from "./columns" type GitHubEventType = GitHubSubscriptionModel.GitHubEventType export const githubSubscriptionsTable = PG.table("github_subscriptions", { columns: { - id: PG.column(PG.brand(PG.uuid, GitHubSubscriptionId), { defaultExpr: "gen_random_uuid()" }), - channelId: PG.brand(PG.uuid, ChannelId), - organizationId: PG.brand(PG.uuid, OrganizationId), + id: PG.column(uuid(GitHubSubscriptionId), { defaultExpr: "gen_random_uuid()" }), + channelId: uuid(ChannelId), + organizationId: uuid(OrganizationId), repositoryId: PG.int8, repositoryFullName: varchar(255), repositoryOwner: varchar(255), @@ -17,7 +17,7 @@ export const githubSubscriptionsTable = PG.table("github_subscriptions", { enabledEvents: PG.column(jsonb(), { default: ["push", "pull_request", "issues"] }), branchFilter: PG.nullable(varchar(255)), isEnabled: PG.column(PG.bool, { default: true }), - createdBy: PG.brand(PG.uuid, UserId), + createdBy: uuid(UserId), createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), deletedAt: PG.nullable(timestamptz), diff --git a/packages/db/src/tables/index.ts b/packages/db/src/tables/index.ts index 09153cd81..10dd0fd9d 100644 --- a/packages/db/src/tables/index.ts +++ b/packages/db/src/tables/index.ts @@ -25,4 +25,4 @@ export * from "./rss-subscriptions" export * from "./typing-indicators" export * from "./user-presence-status" export * from "./users" -export { jsonb, now, pgEnum, timestamptz, typed, varchar } from "./columns" +export { jsonb, now, pgEnum, timestamptz, typed, uuid, varchar } from "./columns" diff --git a/packages/db/src/tables/integration-connections.ts b/packages/db/src/tables/integration-connections.ts index 7b67db2da..81f8b4af1 100644 --- a/packages/db/src/tables/integration-connections.ts +++ b/packages/db/src/tables/integration-connections.ts @@ -1,6 +1,6 @@ import { IntegrationConnectionId, OrganizationId, UserId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { jsonb, pgEnum, timestamptz, typed, varchar } from "./columns" +import { jsonb, now, pgEnum, timestamptz, typed, uuid, varchar } from "./columns" export const connectionLevelEnum = pgEnum("connection_level", ["organization", "user"]) export const connectionStatusEnum = pgEnum("connection_status", [ @@ -13,15 +13,15 @@ export const connectionStatusEnum = pgEnum("connection_status", [ export const integrationConnectionsTable = PG.table("integration_connections", { columns: { - id: PG.column(PG.brand(PG.uuid, IntegrationConnectionId), { defaultExpr: "gen_random_uuid()" }), + id: PG.column(uuid(IntegrationConnectionId), { defaultExpr: "gen_random_uuid()" }), provider: typed<"linear" | "github" | "figma" | "notion" | "discord" | "craft">()(varchar(50)), - organizationId: PG.brand(PG.uuid, OrganizationId), - userId: PG.nullable(PG.brand(PG.uuid, UserId)), + organizationId: uuid(OrganizationId), + userId: PG.nullable(uuid(UserId)), level: connectionLevelEnum, status: PG.column(connectionStatusEnum, { default: "active" }), externalAccountId: PG.nullable(varchar(255)), externalAccountName: PG.nullable(varchar(255)), - connectedBy: PG.brand(PG.uuid, UserId), + connectedBy: uuid(UserId), settings: PG.nullable(jsonb>()), metadata: PG.nullable(jsonb>()), errorMessage: PG.nullable(PG.text), diff --git a/packages/db/src/tables/integration-requests.ts b/packages/db/src/tables/integration-requests.ts index 60f8828b0..c924f49e7 100644 --- a/packages/db/src/tables/integration-requests.ts +++ b/packages/db/src/tables/integration-requests.ts @@ -1,6 +1,6 @@ import { IntegrationRequestId, OrganizationId, UserId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { pgEnum, timestamptz, varchar } from "./columns" +import { now, pgEnum, timestamptz, uuid, varchar } from "./columns" export const integrationRequestStatusEnum = pgEnum("integration_request_status", [ "pending", @@ -11,9 +11,9 @@ export const integrationRequestStatusEnum = pgEnum("integration_request_status", export const integrationRequestsTable = PG.table("integration_requests", { columns: { - id: PG.column(PG.brand(PG.uuid, IntegrationRequestId), { defaultExpr: "gen_random_uuid()" }), - organizationId: PG.brand(PG.uuid, OrganizationId), - requestedBy: PG.brand(PG.uuid, UserId), + id: PG.column(uuid(IntegrationRequestId), { defaultExpr: "gen_random_uuid()" }), + organizationId: uuid(OrganizationId), + requestedBy: uuid(UserId), integrationName: varchar(255), integrationUrl: PG.nullable(varchar(500)), description: PG.nullable(PG.text), diff --git a/packages/db/src/tables/integration-tokens.ts b/packages/db/src/tables/integration-tokens.ts index a18636f16..8fbe6785a 100644 --- a/packages/db/src/tables/integration-tokens.ts +++ b/packages/db/src/tables/integration-tokens.ts @@ -1,11 +1,11 @@ import { IntegrationConnectionId, IntegrationTokenId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { timestamptz, varchar } from "./columns" +import { now, timestamptz, uuid, varchar } from "./columns" export const integrationTokensTable = PG.table("integration_tokens", { columns: { - id: PG.column(PG.brand(PG.uuid, IntegrationTokenId), { defaultExpr: "gen_random_uuid()" }), - connectionId: PG.brand(PG.uuid, IntegrationConnectionId), + id: PG.column(uuid(IntegrationTokenId), { defaultExpr: "gen_random_uuid()" }), + connectionId: uuid(IntegrationConnectionId), encryptedAccessToken: PG.text, encryptedRefreshToken: PG.nullable(PG.text), iv: varchar(32), diff --git a/packages/db/src/tables/message-outbox.ts b/packages/db/src/tables/message-outbox.ts index 807d198d7..a1d620ec0 100644 --- a/packages/db/src/tables/message-outbox.ts +++ b/packages/db/src/tables/message-outbox.ts @@ -1,6 +1,6 @@ import { ChannelId, MessageOutboxEventId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { jsonb, pgEnum, timestamptz, varchar } from "./columns" +import { jsonb, now, pgEnum, timestamptz, uuid, varchar } from "./columns" export const messageOutboxEventTypeEnum = pgEnum("message_outbox_event_type", [ "message_created", @@ -18,11 +18,11 @@ export const messageOutboxEventStatusEnum = pgEnum("message_outbox_event_status" export const messageOutboxEventsTable = PG.table("message_outbox_events", { columns: { - id: PG.column(PG.brand(PG.uuid, MessageOutboxEventId), { defaultExpr: "gen_random_uuid()" }), + id: PG.column(uuid(MessageOutboxEventId), { defaultExpr: "gen_random_uuid()" }), sequence: PG.column(PG.int8, { identity: "always" }), eventType: messageOutboxEventTypeEnum, aggregateId: PG.uuid, - channelId: PG.brand(PG.uuid, ChannelId), + channelId: uuid(ChannelId), payload: jsonb>(), status: PG.column(messageOutboxEventStatusEnum, { default: "pending" }), attemptCount: PG.column(PG.int4, { default: 0 }), diff --git a/packages/db/src/tables/messages.ts b/packages/db/src/tables/messages.ts index bf7a7cbbe..19471bc0d 100644 --- a/packages/db/src/tables/messages.ts +++ b/packages/db/src/tables/messages.ts @@ -1,6 +1,6 @@ import { ChannelId, ConnectConversationId, MessageId, MessageReactionId, UserId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { jsonb, timestamptz, varchar } from "./columns" +import { jsonb, now, timestamptz, uuid, varchar } from "./columns" // Embed types for JSONB column export interface MessageEmbedAuthor { @@ -65,11 +65,11 @@ export interface MessageEmbed { export const messageReactionsTable = PG.table("message_reactions", { columns: { - id: PG.column(PG.brand(PG.uuid, MessageReactionId), { defaultExpr: "gen_random_uuid()" }), - messageId: PG.brand(PG.uuid, MessageId), - channelId: PG.brand(PG.uuid, ChannelId), - conversationId: PG.nullable(PG.brand(PG.uuid, ConnectConversationId)), - userId: PG.brand(PG.uuid, UserId), + id: PG.column(uuid(MessageReactionId), { defaultExpr: "gen_random_uuid()" }), + messageId: uuid(MessageId), + channelId: uuid(ChannelId), + conversationId: PG.nullable(uuid(ConnectConversationId)), + userId: uuid(UserId), emoji: varchar(50), createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), }, @@ -88,14 +88,14 @@ export type NewMessageReaction = PG.InsertRowOf export const messagesTable = PG.table("messages", { columns: { - id: PG.column(PG.brand(PG.uuid, MessageId), { defaultExpr: "gen_random_uuid()" }), - channelId: PG.brand(PG.uuid, ChannelId), - conversationId: PG.nullable(PG.brand(PG.uuid, ConnectConversationId)), - authorId: PG.brand(PG.uuid, UserId), + id: PG.column(uuid(MessageId), { defaultExpr: "gen_random_uuid()" }), + channelId: uuid(ChannelId), + conversationId: PG.nullable(uuid(ConnectConversationId)), + authorId: uuid(UserId), content: PG.text, embeds: PG.nullable(jsonb()), - replyToMessageId: PG.nullable(PG.brand(PG.uuid, MessageId)), - threadChannelId: PG.nullable(PG.brand(PG.uuid, ChannelId)), + replyToMessageId: PG.nullable(uuid(MessageId)), + threadChannelId: PG.nullable(uuid(ChannelId)), createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), updatedAt: PG.nullable(timestamptz), deletedAt: PG.nullable(timestamptz), diff --git a/packages/db/src/tables/notifications.ts b/packages/db/src/tables/notifications.ts index e538ed935..c11f50dcc 100644 --- a/packages/db/src/tables/notifications.ts +++ b/packages/db/src/tables/notifications.ts @@ -1,11 +1,11 @@ import { NotificationId, OrganizationMemberId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { timestamptz, varchar } from "./columns" +import { now, timestamptz, uuid, varchar } from "./columns" export const notificationsTable = PG.table("notifications", { columns: { - id: PG.column(PG.brand(PG.uuid, NotificationId), { defaultExpr: "gen_random_uuid()" }), - memberId: PG.brand(PG.uuid, OrganizationMemberId), + id: PG.column(uuid(NotificationId), { defaultExpr: "gen_random_uuid()" }), + memberId: uuid(OrganizationMemberId), targetedResourceId: PG.nullable(PG.uuid), targetedResourceType: PG.nullable(varchar(50)), resourceId: PG.nullable(PG.uuid), diff --git a/packages/db/src/tables/organizations.ts b/packages/db/src/tables/organizations.ts index 0835f6ddb..6c843d6b9 100644 --- a/packages/db/src/tables/organizations.ts +++ b/packages/db/src/tables/organizations.ts @@ -1,12 +1,12 @@ import { OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { jsonb, pgEnum, timestamptz, varchar } from "./columns" +import { jsonb, now, pgEnum, timestamptz, uuid, varchar } from "./columns" export const organizationRoleEnum = pgEnum("organization_role", ["admin", "member", "owner"]) export const organizationsTable = PG.table("organizations", { columns: { - id: PG.column(PG.brand(PG.uuid, OrganizationId), { defaultExpr: "gen_random_uuid()" }), + id: PG.column(uuid(OrganizationId), { defaultExpr: "gen_random_uuid()" }), name: varchar(255), slug: PG.nullable(varchar(100)), logoUrl: PG.nullable(PG.text), @@ -33,13 +33,13 @@ export type NewOrganization = PG.InsertRowOf export const organizationMembersTable = PG.table("organization_members", { columns: { - id: PG.column(PG.brand(PG.uuid, OrganizationMemberId), { defaultExpr: "gen_random_uuid()" }), - organizationId: PG.brand(PG.uuid, OrganizationId), - userId: PG.brand(PG.uuid, UserId), + id: PG.column(uuid(OrganizationMemberId), { defaultExpr: "gen_random_uuid()" }), + organizationId: uuid(OrganizationId), + userId: uuid(UserId), role: PG.column(organizationRoleEnum, { default: "member" }), nickname: PG.nullable(varchar(100)), joinedAt: PG.column(timestamptz, { defaultExpr: "now()" }), - invitedBy: PG.nullable(PG.brand(PG.uuid, UserId)), + invitedBy: PG.nullable(uuid(UserId)), metadata: PG.nullable(jsonb()), createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), deletedAt: PG.nullable(timestamptz), diff --git a/packages/db/src/tables/pinned-messages.ts b/packages/db/src/tables/pinned-messages.ts index ba36374de..b26c46d7a 100644 --- a/packages/db/src/tables/pinned-messages.ts +++ b/packages/db/src/tables/pinned-messages.ts @@ -1,13 +1,13 @@ import { ChannelId, MessageId, PinnedMessageId, UserId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { timestamptz } from "./columns" +import { now, timestamptz, uuid } from "./columns" export const pinnedMessagesTable = PG.table("pinned_messages", { columns: { - id: PG.column(PG.brand(PG.uuid, PinnedMessageId), { defaultExpr: "gen_random_uuid()" }), - channelId: PG.brand(PG.uuid, ChannelId), - messageId: PG.brand(PG.uuid, MessageId), - pinnedBy: PG.brand(PG.uuid, UserId), + id: PG.column(uuid(PinnedMessageId), { defaultExpr: "gen_random_uuid()" }), + channelId: uuid(ChannelId), + messageId: uuid(MessageId), + pinnedBy: uuid(UserId), pinnedAt: PG.column(timestamptz, { defaultExpr: "now()" }), }, primaryKey: ["id"], diff --git a/packages/db/src/tables/rss-subscriptions.ts b/packages/db/src/tables/rss-subscriptions.ts index 417d3b661..431685d9a 100644 --- a/packages/db/src/tables/rss-subscriptions.ts +++ b/packages/db/src/tables/rss-subscriptions.ts @@ -1,14 +1,14 @@ import { ChannelId, MessageId, OrganizationId, RssSubscriptionId, UserId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { timestamptz, varchar } from "./columns" +import { now, timestamptz, uuid, varchar } from "./columns" export const rssPostedItemsTable = PG.table("rss_posted_items", { columns: { id: PG.column(PG.uuid, { defaultExpr: "gen_random_uuid()" }), - subscriptionId: PG.brand(PG.uuid, RssSubscriptionId), + subscriptionId: uuid(RssSubscriptionId), itemGuid: PG.text, itemUrl: PG.nullable(PG.text), - messageId: PG.nullable(PG.brand(PG.uuid, MessageId)), + messageId: PG.nullable(uuid(MessageId)), postedAt: PG.column(timestamptz, { defaultExpr: "now()" }), }, primaryKey: ["id"], @@ -22,9 +22,9 @@ export type NewRssPostedItem = PG.InsertRowOf export const rssSubscriptionsTable = PG.table("rss_subscriptions", { columns: { - id: PG.column(PG.brand(PG.uuid, RssSubscriptionId), { defaultExpr: "gen_random_uuid()" }), - channelId: PG.brand(PG.uuid, ChannelId), - organizationId: PG.brand(PG.uuid, OrganizationId), + id: PG.column(uuid(RssSubscriptionId), { defaultExpr: "gen_random_uuid()" }), + channelId: uuid(ChannelId), + organizationId: uuid(OrganizationId), feedUrl: PG.text, feedTitle: PG.nullable(varchar(255)), feedIconUrl: PG.nullable(PG.text), @@ -36,7 +36,7 @@ export const rssSubscriptionsTable = PG.table("rss_subscriptions", { lastErrorAt: PG.nullable(timestamptz), isEnabled: PG.column(PG.bool, { default: true }), pollingIntervalMinutes: PG.column(PG.int4, { default: 15 }), - createdBy: PG.brand(PG.uuid, UserId), + createdBy: uuid(UserId), createdAt: PG.column(timestamptz, { defaultExpr: "now()" }), updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), deletedAt: PG.nullable(timestamptz), diff --git a/packages/db/src/tables/typing-indicators.ts b/packages/db/src/tables/typing-indicators.ts index ab7b783ee..a5a53e9cf 100644 --- a/packages/db/src/tables/typing-indicators.ts +++ b/packages/db/src/tables/typing-indicators.ts @@ -1,12 +1,12 @@ import { ChannelId, ChannelMemberId, TypingIndicatorId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import {} from "./columns" +import { uuid } from "./columns" export const typingIndicatorsTable = PG.table("typing_indicators", { columns: { - id: PG.column(PG.brand(PG.uuid, TypingIndicatorId), { defaultExpr: "gen_random_uuid()" }), - channelId: PG.brand(PG.uuid, ChannelId), - memberId: PG.brand(PG.uuid, ChannelMemberId), + id: PG.column(uuid(TypingIndicatorId), { defaultExpr: "gen_random_uuid()" }), + channelId: uuid(ChannelId), + memberId: uuid(ChannelMemberId), lastTyped: PG.int8, }, primaryKey: ["id"], diff --git a/packages/db/src/tables/user-presence-status.ts b/packages/db/src/tables/user-presence-status.ts index 4512d6511..76cb52f54 100644 --- a/packages/db/src/tables/user-presence-status.ts +++ b/packages/db/src/tables/user-presence-status.ts @@ -1,6 +1,6 @@ import { ChannelId, UserId, UserPresenceStatusId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { pgEnum, timestamptz, varchar } from "./columns" +import { now, pgEnum, timestamptz, uuid, varchar } from "./columns" export const userPresenceStatusEnum = pgEnum("user_presence_status_enum", [ "online", @@ -12,13 +12,13 @@ export const userPresenceStatusEnum = pgEnum("user_presence_status_enum", [ export const userPresenceStatusTable = PG.table("user_presence_status", { columns: { - id: PG.column(PG.brand(PG.uuid, UserPresenceStatusId), { defaultExpr: "gen_random_uuid()" }), - userId: PG.brand(PG.uuid, UserId), + id: PG.column(uuid(UserPresenceStatusId), { defaultExpr: "gen_random_uuid()" }), + userId: uuid(UserId), status: PG.column(userPresenceStatusEnum, { default: "online" }), customMessage: PG.nullable(varchar(255)), statusEmoji: PG.nullable(varchar(10)), statusExpiresAt: PG.nullable(timestamptz), - activeChannelId: PG.nullable(PG.brand(PG.uuid, ChannelId)), + activeChannelId: PG.nullable(uuid(ChannelId)), suppressNotifications: PG.column(PG.bool, { default: false }), updatedAt: PG.column(timestamptz, { defaultExpr: "now()" }), lastSeenAt: PG.column(timestamptz, { defaultExpr: "now()" }), diff --git a/packages/db/src/tables/users.ts b/packages/db/src/tables/users.ts index 24c3f5ccb..e558ae074 100644 --- a/packages/db/src/tables/users.ts +++ b/packages/db/src/tables/users.ts @@ -1,12 +1,12 @@ import { UserId } from "@hazel/schema" import * as PG from "@maple-dev/effect-orm/postgres" -import { jsonb, pgEnum, timestamptz, varchar } from "./columns" +import { jsonb, now, pgEnum, timestamptz, uuid, varchar } from "./columns" export const userTypeEnum = pgEnum("user_type", ["user", "machine"]) export const usersTable = PG.table("users", { columns: { - id: PG.column(PG.brand(PG.uuid, UserId), { defaultExpr: "gen_random_uuid()" }), + id: PG.column(uuid(UserId), { defaultExpr: "gen_random_uuid()" }), externalId: varchar(255), email: varchar(255), firstName: varchar(100),