Compare commits
2 commits
4a1bea95c5
...
ff01a5f097
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff01a5f097 | ||
|
|
a9a4ccb9f2 |
135 changed files with 42105 additions and 264 deletions
117
.convoys/ai-agent-studio.md
Normal file
117
.convoys/ai-agent-studio.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
---
|
||||
name: ai-agent-studio
|
||||
classification: feature
|
||||
success_metric: The existing AI rail destination provides accessible Chat and Agents modes, versioned agent foundations, and a provider-agnostic service that streams validated agent draft proposals.
|
||||
skip: []
|
||||
status: in-progress
|
||||
created: 2026-09-25
|
||||
model_policy:
|
||||
roles:
|
||||
role-conductor: claude-planning
|
||||
role-architect: claude-planning
|
||||
role-implementer: qwen-coder
|
||||
role-reviewer: qwen-coder
|
||||
escalate_to: claude-planning
|
||||
---
|
||||
|
||||
# AI agent studio and marketplace
|
||||
|
||||
## Why
|
||||
|
||||
The existing AI rail destination is already the natural home for AI interaction, but it currently provides only one stateless full-page chat. Users should be able to chat with AI or switch modes and use AI itself to create, review, install, and manage reusable agents. This should feel like one coherent AI workspace rather than separate Chat, Settings, and marketplace products.
|
||||
|
||||
Canonical backlog: `plans/Plan-workspace-communications/Epic-agent-participation/Epic-agent-participation.md`.
|
||||
|
||||
## Scope
|
||||
|
||||
- Brief 1: refactor `/<workspaceSlug>/ai` into URL-backed Chat and Agents tabs with one shared composer shell.
|
||||
- Brief 2: add first-class, versioned agent persistence and shared validation/permission contracts.
|
||||
- Brief 3: turn Agents-mode prompts into validated clarification and structured draft-patch events; it does not implement the editor or persistence.
|
||||
- Follow-on backlog tasks add the builder editor/private-draft persistence, marketplace library UI, permission review/sandbox/activation, durable jobs, direct saved-agent chat, channel membership, harness events, and chat delivery.
|
||||
- Preserve the existing Sparkles `AI` rail entry; do not add a separate Agents rail item.
|
||||
- Reuse model routing, harness routing, template UI patterns, and workspace authorization without conflating those records with agent identity.
|
||||
|
||||
Out of scope: channel participation/jobs, commercial marketplace billing/ratings, arbitrary public publishing, and autonomous channel access. Those follow after the agent registry exists.
|
||||
|
||||
## Role activity and kickoff — 2026-10-22
|
||||
|
||||
- User approved beginning the integrated chat/agent direction, not merge or release.
|
||||
- This session performed conductor reconciliation and architect access/registry/dependency review. Existing IA/UX/architecture sections are design guidance, not evidence of historical role completion; independent IA, UX and reviewer completion is not asserted.
|
||||
- Brief 1 and its canonical task are `in_progress`: an independent implementer owns the AI shell app changes. This planning session changes no app code. Brief 2 is ready; Brief 3 waits for its dependencies.
|
||||
- `in-progress` is local README bookkeeping for the active brief. No DB registration, task claim, event logging or MCP transition occurred; those tools are unavailable here.
|
||||
|
||||
## Full product versus this foundation
|
||||
|
||||
These three briefs provide a shell, schema/contracts, and inert builder service—not a working marketplace, install API, contextual chat or agent execution. The [registry API task](../plans/Plan-workspace-communications/Epic-agent-participation/Task-agent-registry-api.md) and [install/lifecycle task](../plans/Plan-workspace-communications/Epic-agent-participation/Task-agent-install-lifecycle.md) now explicitly own the missing server boundaries. Library/editor/review tasks consume them rather than creating duplicate registries or lifecycle endpoints.
|
||||
|
||||
AI Chat, Agents creation/library, channel mentions and durable jobs use **one workspace agent registry**. Installation/activation does not grant conversation access. Follow the [plan's context and mention-to-add contracts](../plans/Plan-workspace-communications/Plan-workspace-communications.md): channel addition or expanded access needs authorized explicit confirmation, reviewed version/history/tool scope, idempotency, and revocation checks; workspace role alone cannot grant private-object access.
|
||||
|
||||
## Todos
|
||||
|
||||
- [x] User approved kickoff of the AI Chat/Agents direction on 2026-10-22; merge/release gates remain pending.
|
||||
- [ ] Implement the Chat/Agents shell without regressing existing AI chat.
|
||||
- [ ] Implement versioned agent persistence and shared registry contracts.
|
||||
- [ ] Implement provider-agnostic conversational clarification and validated structured draft-patch streaming.
|
||||
- [ ] Reviewer validates tenant isolation, permission safety, provider agnosticism, accessibility, and spec alignment.
|
||||
- [ ] Human performs merge and release gates.
|
||||
|
||||
## IA
|
||||
|
||||
- Keep `/<workspaceSlug>/ai` as the canonical route and Sparkles rail destination.
|
||||
- Use URL-backed top-level tabs: `Chat` and `Agents`; Chat remains the default for old links.
|
||||
- The shared composer stays centered and visually stable across modes.
|
||||
- Chat preserves current conversation behavior; saved-agent selection arrives in the direct-agent-chat follow-on, not the shell brief.
|
||||
- Agents renders scaffolded library regions under/around the composer; the follow-on marketplace task populates Featured/Templates, Installed, Created by your workspace, and Drafts.
|
||||
- This convoy provides placeholders for future agent cards/detail actions and focuses `Create new` on the builder composer.
|
||||
- Brief 3 exposes testable builder service events; the follow-on editor wires them into Agents mode and turns them into editable, persisted drafts.
|
||||
|
||||
## UX
|
||||
|
||||
- Switching tabs changes intent, placeholder, suggestions, submit label, and result content—not the fundamental composer size or interaction language.
|
||||
- Agents-mode examples include outcome-oriented prompts rather than raw configuration fields.
|
||||
- Validated builder events expose what the proposed agent could read, requested tools, invocation policy, budget, and requested model role/harness route; the follow-on editor presents the full review experience.
|
||||
- Draft proposal generation never persists, activates, publishes, grants tools, or joins channels automatically.
|
||||
- Marketplace lifecycle/provenance visuals are owned by the follow-on library task; this convoy reserves compatible states in the shell.
|
||||
- Keyboard focus survives tab switches where sensible; tabs, cards, dialogs, draft fields, and permission review are accessible.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Product boundaries
|
||||
|
||||
- An agent definition is a stable service identity and versioned policy bundle.
|
||||
- A role is a requested capability used by model/harness routing; it is not an agent identity.
|
||||
- A model provider or OmniRoute profile performs/routes inference; it is not an agent.
|
||||
- A harness executes a run; it is not an agent.
|
||||
- An `agent_run` is execution history and references the pinned agent version where applicable.
|
||||
|
||||
### Builder flow
|
||||
|
||||
1. The Agents tab submits a workspace-authorized builder request through a dedicated typed streaming endpoint, not the normal chat endpoint.
|
||||
2. The builder resolves a model role through existing routing and returns validated clarification or structured draft-patch events.
|
||||
3. This convoy stops at the typed event stream; it does not edit or persist drafts.
|
||||
4. A follow-on editor consumes those events and saves private draft versions owned by the current member.
|
||||
5. A later review flow handles permission approval, sandbox tests, install, activation, and workspace publication.
|
||||
|
||||
### Marketplace model
|
||||
|
||||
- Schemas/contracts support global curated templates that are readable but not tenant-owned; catalog seeding and browse APIs are follow-on work.
|
||||
- Workspace agents/installations, drafts, permissions, and versions are always scoped by `workspace_id`.
|
||||
- Installation contracts require workspace-owned versioned configuration and prohibit copying secrets.
|
||||
- First release supports curated templates and workspace sharing, not open public submissions, ratings, or payments.
|
||||
|
||||
### Security
|
||||
|
||||
- Generated output is untrusted and must pass zod validation plus server-side policy checks.
|
||||
- Tool grants, channel access, budgets, and activation are never inferred as silent side effects.
|
||||
- Provider credentials remain referenced through existing provider configuration and are never embedded in an agent definition or prompt transcript.
|
||||
- Agent handles are unique per workspace; immutable IDs/version IDs drive references and audit history.
|
||||
|
||||
## Briefs
|
||||
|
||||
| Brief | Backlog task | Depends on |
|
||||
| --- | --- | --- |
|
||||
| 1 | `Task-ai-workspace-tabs.md` | none |
|
||||
| 2 | `Task-agent-registry-marketplace.md` | none; may proceed in parallel with brief 1 |
|
||||
| 3 | `Task-ai-assisted-agent-builder.md` | briefs 1 and 2 |
|
||||
|
||||
Follow-on scheduling: registry API → install/lifecycle → library and editor/review UI; durable jobs → direct-chat and reviewed channel membership; normalized harness → sandbox execution and run delivery. UI-only review scaffolding can precede the harness, but sandbox execution cannot. Contextual channel integration additionally waits for a verified object access policy. These are canonical backlog dependencies, not extra deliverables of the three foundation briefs.
|
||||
50
.convoys/ai-agent-studio/brief-1-ai-workspace-tabs.md
Normal file
50
.convoys/ai-agent-studio/brief-1-ai-workspace-tabs.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
brief: 1
|
||||
convoy: ai-agent-studio
|
||||
role: role-implementer
|
||||
title: AI workspace Chat and Agents tabs
|
||||
status: in_progress
|
||||
priority: P1
|
||||
slice_dependencies: []
|
||||
recommended_model: auto/coding
|
||||
model_tier: standard
|
||||
---
|
||||
|
||||
# Brief 1 — `Task-ai-workspace-tabs.md`
|
||||
|
||||
## Execution note — 2026-10-22
|
||||
|
||||
User approved kickoff. The code slice and `/api/chat` role-boundary hardening are implemented; standards/spec reviews are clean, web tests pass (34 tests), and web type-check/lint pass with pre-existing warnings only. Browser verification remains required for native back/forward behavior, Radix keyboard focus, streaming scroll restoration, responsive layout, and screen-reader announcements, so the brief remains in progress. No merge/release or DB transition is claimed. Agents remains an explicit placeholder/adapter stub: the shell must not claim working creation, installation, mention-to-add, saved-agent execution, or private-context access.
|
||||
|
||||
## Goal
|
||||
|
||||
Refactor the existing workspace AI page into accessible, deep-linkable Chat and Agents modes that share one visual composer shell without regressing current streaming chat.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/web/app/(app)/[workspaceSlug]/ai/page.tsx` — preserve the canonical route; split orchestration from presentation.
|
||||
- `apps/web/components/ai/` — add a workspace frame, URL-backed tab switcher, shared composer shell, Chat mode, and Agents placeholder/library frame.
|
||||
- Existing `/api/chat` integration — preserve for Chat mode only.
|
||||
- Adjacent component tests.
|
||||
|
||||
## Required design
|
||||
|
||||
- Keep the existing `AI` Sparkles item in `apps/web/components/layout/icon-rail.tsx`; do not add an Agents rail item.
|
||||
- `Chat` is the default for `/<workspaceSlug>/ai`; tab state is URL-backed and browser-navigation safe.
|
||||
- The composer keeps the same dimensions, shape, attachment/action affordances, and keyboard conventions in both modes.
|
||||
- Mode-specific behavior is explicit: Chat submits conversation messages; Agents emits typed builder requests through a separate adapter stub until Brief 3.
|
||||
- Tab changes preserve each mode's independent draft input and relevant scroll state.
|
||||
- Reuse existing UI primitives and responsive shell patterns.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Old AI links still open functional Chat mode.
|
||||
- Users can switch/deep-link/refresh/back-forward between Chat and Agents.
|
||||
- Chat streaming, error, stop, and focus behavior remains functional and workspace-scoped.
|
||||
- Agents shows the shared composer plus library loading/empty/error scaffolding without submitting to `/api/chat`.
|
||||
- Tabs and composer pass keyboard/screen-reader checks.
|
||||
- `pnpm lint && pnpm type-check && pnpm test` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
Read the current AI page, `/api/chat` route, icon rail, tRPC provider, and UI primitives before editing. Separate shared presentation from mode-specific state; do not introduce the registry schema or persist generated agents in this brief.
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
---
|
||||
brief: 2
|
||||
convoy: ai-agent-studio
|
||||
role: role-implementer
|
||||
title: Versioned agent registry schema and contracts
|
||||
status: ready
|
||||
priority: P1
|
||||
slice_dependencies: []
|
||||
recommended_model: auto/coding
|
||||
model_tier: standard
|
||||
---
|
||||
|
||||
# Brief 2 — `Task-agent-registry-marketplace.md`
|
||||
|
||||
## Goal
|
||||
|
||||
Add durable workspace agent definitions and shared validation/permission contracts that later draft, marketplace, and builder APIs can consume.
|
||||
|
||||
## Files
|
||||
|
||||
Expected scope; confirm names against current exports.
|
||||
|
||||
- `packages/database/src/schema/` and a generated migration — template, agent, version, and installation/provenance records.
|
||||
- `packages/shared/src/` — zod contracts for identity, configuration, policies, versions, permissions, and marketplace filters.
|
||||
- Adjacent schema/contract/permission-policy tests.
|
||||
|
||||
## Required design
|
||||
|
||||
- Agents are not `users`, role IDs, provider aliases, harness aliases, or `agent_runs`. This is the single identity/version registry for AI Chat, Agents builder/library, mentions, installations and jobs; never add a second channel-agent registry.
|
||||
- Separate lifecycle/tool approval roles from context delegation and per-conversation access. Installation/activation grants no channel/private-object access. Follow the parent plan's permission matrix; schemas/contracts must support reviewed, version-bound scoped grants without inventing project ACL tables.
|
||||
- Workspace agents have immutable IDs, unique workspace handles, versioned configuration, lifecycle status, provenance, and creator/audit metadata.
|
||||
- Configuration references model role and harness route aliases but contains no provider secrets.
|
||||
- Schemas support global curated-template provenance and workspace-owned installations without storing provider secrets.
|
||||
- Reusable policy contracts encode: members manage their own private drafts/use active agents; `admin`/`owner` manage template installation, shared lifecycle, and ordinary allowlisted grants; only `owner` approves mutating sandbox policy or sensitive workspace-wide tools.
|
||||
- Contracts expose lifecycle, visibility, provenance, permissions, and update state for later APIs and UI.
|
||||
|
||||
## Follow-on boundaries
|
||||
|
||||
[Registry API](../../plans/Plan-workspace-communications/Epic-agent-participation/Task-agent-registry-api.md) owns catalog/private-draft procedures. [Install lifecycle](../../plans/Plan-workspace-communications/Epic-agent-participation/Task-agent-install-lifecycle.md) owns reviewed lifecycle transitions. These are not hidden deliverables of this schema brief; UI tasks consume them.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Tenant ownership, handle uniqueness, and supporting indexes/foreign keys are enforced at the schema/contract layer; workspace-scoped query enforcement is required in the follow-on CRUD APIs.
|
||||
- Version/updated-at precondition contracts prevent silent last-write-wins behavior in later APIs.
|
||||
- Contracts cover capabilities, requested/granted tools, context/invocation policy, model role, harness route, budget, provenance, permissions, and version changes without weakening private-draft boundaries.
|
||||
- No schema or contract treats existing workflow role IDs as agent identities.
|
||||
- `pnpm db:generate`, `pnpm lint`, `pnpm type-check`, and relevant tests pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
Read existing model/harness registry schemas and settings UI, template CRUD/editor patterns, workspace authorization helpers, and schema exports. Keep this brief focused on registry/marketplace; do not implement channel participation or builder inference.
|
||||
49
.convoys/ai-agent-studio/brief-3-ai-agent-builder.md
Normal file
49
.convoys/ai-agent-studio/brief-3-ai-agent-builder.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
---
|
||||
brief: 3
|
||||
convoy: ai-agent-studio
|
||||
role: role-implementer
|
||||
title: Conversational AI agent builder
|
||||
status: blocked
|
||||
priority: P1
|
||||
slice_dependencies:
|
||||
- brief-1-ai-workspace-tabs
|
||||
- brief-2-agent-registry-marketplace
|
||||
recommended_model: auto/coding
|
||||
model_tier: standard
|
||||
---
|
||||
|
||||
# Brief 3 — `Task-ai-assisted-agent-builder.md` (service slice)
|
||||
|
||||
## Goal
|
||||
|
||||
Implement the provider-agnostic builder service and streaming contracts that transform an Agents-mode request into validated clarification or structured draft-patch events. UI editing and private-draft persistence remain a follow-on slice.
|
||||
|
||||
## Files
|
||||
|
||||
Expected scope; confirm routing conventions before implementation.
|
||||
|
||||
- `packages/shared/src/` — versioned builder request/event/draft-patch schemas.
|
||||
- `packages/ai/src/prompts/` — agent-builder prompts and structured-output instructions.
|
||||
- `apps/web/app/api/` or a suitable streaming tRPC boundary — workspace-authorized builder stream separate from `/api/chat`.
|
||||
- Adjacent service tests for validation, workspace authorization, provider abstraction, stream event ordering, and malformed output.
|
||||
|
||||
## Required design
|
||||
|
||||
- The builder requests an existing model role/capability and uses provider resolution; no direct OmniRoute/provider branch exists in UI or route code.
|
||||
- Model output is untrusted. Only versioned zod-validated clarification and draft-patch events may affect client state.
|
||||
- The service emits deterministic base revision IDs and structured patches so a follow-on UI can maintain conflict-safe draft history.
|
||||
- Generated requested tools, channel access, activation, and workspace publication remain inert, unapproved fields in validated output.
|
||||
- The endpoint does not persist, publish, install, activate, or execute an agent.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Outcome-oriented prompts create valid drafts or targeted clarification questions.
|
||||
- The service supports initial requests and follow-up refinements through validated clarification/draft-patch events.
|
||||
- Invalid or policy-violating model output becomes a typed error event and is never persisted.
|
||||
- Requested tools and access remain marked unapproved and cannot be exercised by this endpoint.
|
||||
- No plaintext provider secret appears in events, logs, or client payloads.
|
||||
- `pnpm lint && pnpm type-check && pnpm test` pass; `pnpm build` passes for changed server/AI exports.
|
||||
|
||||
## Prompt
|
||||
|
||||
Read model routing, `packages/ai` provider adaptation, prompt conventions, registry contracts from Brief 2, and workspace guards. Use the existing AI SDK only as a normalized model API; keep the builder compatible with OmniRoute and other providers through the resolved model contract. Do not build the editor or draft CRUD in this brief.
|
||||
47
.convoys/repo-discovery-onboarding.md
Normal file
47
.convoys/repo-discovery-onboarding.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
name: repo-discovery-onboarding
|
||||
classification: feature
|
||||
success_metric: Operators can authorize GitHub or configure Forgejo/Gitea, discover their repositories, bulk-connect selected repos, import supported repository context, and receive usable agent onboarding instructions.
|
||||
skip: []
|
||||
status: open
|
||||
created: 2026-09-25
|
||||
model_policy:
|
||||
roles:
|
||||
role-conductor: claude-planning
|
||||
role-architect: claude-planning
|
||||
role-implementer: qwen-coder
|
||||
role-reviewer: qwen-coder
|
||||
escalate_to: claude-planning
|
||||
---
|
||||
|
||||
# Repo discovery and onboarding
|
||||
|
||||
## Why
|
||||
|
||||
Echodo currently requires workspace admins to paste one remote URL at a time and reference a server-side token environment variable. That is adequate for worker execution but too cumbersome for onboarding a fleet of existing repositories.
|
||||
|
||||
The target flow is account/instance connection, repository discovery, explicit selection, context import, and agent setup. `plans/` remains the canonical product backlog; this convoy defines the implementation slices and gates.
|
||||
|
||||
## Scope and security decisions
|
||||
|
||||
- GitHub discovery uses the current user's linked Auth.js GitHub account and its server-side access token. The browser never receives the token.
|
||||
- GitHub OAuth requests the minimum scopes needed for repository discovery. Existing accounts must explicitly re-authorize when their stored scope is insufficient.
|
||||
- Forgejo/Gitea v1 uses an instance URL plus a **token environment-variable reference**. A PAT value is never stored in Echodo or sent back to the browser.
|
||||
- Every tRPC procedure is workspace-scoped. Mutations require workspace owner/admin; discovery additionally verifies that the linked forge identity belongs to the current user.
|
||||
- A connected repository remains a workspace resource. Importing repositories does not automatically create new workspaces.
|
||||
- Repository context import is idempotent and records source repo/path/revision metadata. The canonical remote file is not silently overwritten by Echodo.
|
||||
|
||||
## Delivery order
|
||||
|
||||
1. **Forge discovery API** — shared schemas, credential resolution, paginated GitHub and Forgejo/Gitea discovery, and `connectedRepos.listAvailable`.
|
||||
2. **Repo picker and batch connect** — searchable selection UI and transactional/idempotent bulk connection.
|
||||
3. **Repository context ingestion** — import supported docs and existing convoy manifests with provenance and retryable status.
|
||||
4. **Agent onboarding** — client-specific, copyable MCP setup based on the actual Echodo deployment model.
|
||||
|
||||
Briefs map 1:1 to the matching `plans/Plan-automation-foundation/Epic-repo-discovery-onboarding/Task-*.md` files so the worker can claim and complete backlog work.
|
||||
|
||||
## Gates
|
||||
|
||||
- Gate 1 must confirm the credential boundary above before Brief 1 is dispatched.
|
||||
- Gate 2 reviews auth handling, tenant scoping, pagination, and redaction before merge.
|
||||
- Live GitHub/Forgejo verification uses operator-provided test accounts; automated tests must use mocked HTTP responses and contain no tokens.
|
||||
50
.convoys/repo-discovery-onboarding/brief-1-api.md
Normal file
50
.convoys/repo-discovery-onboarding/brief-1-api.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
brief: 1
|
||||
convoy: repo-discovery-onboarding
|
||||
role: role-implementer
|
||||
title: Forge account repository discovery API
|
||||
status: ready
|
||||
priority: P1
|
||||
slice_dependencies: []
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
workspace_id: global
|
||||
---
|
||||
|
||||
# Brief 1 — Forge account repository discovery API
|
||||
|
||||
## Goal
|
||||
|
||||
A workspace admin can request a normalized, paginated list of repositories visible to either their linked GitHub identity or a configured Forgejo/Gitea instance. Already-connected repositories are identified without exposing credentials to the client.
|
||||
|
||||
Backlog task: `plans/Plan-automation-foundation/Epic-repo-discovery-onboarding/Task-forge-discovery-api.md`.
|
||||
|
||||
## Security and product contract
|
||||
|
||||
- Do not accept or return raw access tokens in tRPC input/output.
|
||||
- GitHub credentials come from the current user's `accounts` row for `provider='github'`; never use another workspace member's account.
|
||||
- Update the existing GitHub provider authorization parameters to request repository discovery scopes. Return a typed `reauthorization_required` state when the stored account lacks the required scope instead of treating it as a forge API failure.
|
||||
- Forgejo/Gitea discovery uses a workspace-admin-supplied instance descriptor and `tokenEnvVar`; resolve the token only on the server with the existing `readToken` pattern.
|
||||
- Only workspace owner/admin may call discovery. All connected-repo comparisons filter by `workspace_id`.
|
||||
- Keep the existing paste-URL flow working.
|
||||
|
||||
## Files
|
||||
|
||||
- `packages/shared/src/connected-repos/schemas.ts` — add zod schemas/types for discovery source, normalized remote repository summary, pagination cursor/page, and typed connection state; export through `packages/shared/src/connected-repos/index.ts`.
|
||||
- `apps/web/lib/auth.ts` — configure GitHub authorization scopes while preserving the existing sign-in and account-linking flow.
|
||||
- `apps/web/server/lib/forge/adapters.ts` — add a discovery context separate from repo-specific `ForgeAdapterContext`; implement paginated GitHub `/user/repos` and Forgejo/Gitea `/user/repos` mapping. Preserve `fetchRepoMetadata` and `createPullRequest` behavior.
|
||||
- `apps/web/server/routers/connected-repos.ts` — add admin-only `listAvailable`; resolve credentials server-side, call the selected adapter, and mark an item connected using the unique tuple `(workspace_id, api_base_url, owner, repo_name)`.
|
||||
- Add focused tests for response validation, pagination, missing credentials/scopes, private repository mapping, and already-connected matching. If web tests require missing infrastructure, add the smallest Vitest setup consistent with existing package configs.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- GitHub and Forgejo/Gitea payloads map to one zod-validated result containing stable remote ID, name/full name, owner, HTTPS clone URL, web URL, default branch, privacy flag, description, and updated time.
|
||||
- Pagination is explicit; the implementation does not silently truncate accounts with more than 100 repositories.
|
||||
- A credentials or scope problem returns a typed connection state suitable for UI remediation and does not leak a token or provider response body.
|
||||
- Existing connected repos are marked accurately within the current workspace only.
|
||||
- Existing manual connect/test/update/disconnect procedures continue to work.
|
||||
- `pnpm --filter @tasks/shared test`, `pnpm --filter @tasks/web lint`, and `pnpm --filter @tasks/web type-check` pass.
|
||||
|
||||
## Prompt for implementer
|
||||
|
||||
Implement only Brief 1. Read `AGENTS.md`, the matching backlog task, `apps/web/lib/auth.ts`, `apps/web/server/lib/forge/adapters.ts`, and `apps/web/server/routers/connected-repos.ts` before editing. Use TDD for adapter normalization and credential-state behavior. Do not build the picker UI, batch connection, document ingestion, or agent configuration in this brief. Do not persist plaintext PATs.
|
||||
40
.convoys/repo-discovery-onboarding/brief-2-ui.md
Normal file
40
.convoys/repo-discovery-onboarding/brief-2-ui.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
brief: 2
|
||||
convoy: repo-discovery-onboarding
|
||||
role: role-implementer
|
||||
title: Repository picker and batch connection
|
||||
status: ready
|
||||
priority: P1
|
||||
slice_dependencies: [1]
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
workspace_id: global
|
||||
---
|
||||
|
||||
# Brief 2 — Repository picker and batch connection
|
||||
|
||||
## Goal
|
||||
|
||||
Workspace admins can search discovered repositories, select multiple entries, and connect them to the current workspace in one idempotent operation while retaining the existing paste-URL workflow.
|
||||
|
||||
Backlog task: `plans/Plan-automation-foundation/Epic-repo-discovery-onboarding/Task-repo-picker-modal.md`.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/web/server/routers/connected-repos.ts` — add zod-validated, admin-only `batchConnect`. Re-resolve or validate selected remote IDs server-side; do not trust client-supplied owner, URLs, forge type, or token references. Insert with workspace scoping and conflict-safe behavior against `connected_repos_workspace_repo_unique`.
|
||||
- `apps/web/app/(app)/[workspaceSlug]/settings/repos/page.tsx` — retain manual connection and add a discovery entry point with source/connection-state handling.
|
||||
- `apps/web/components/settings/repo-picker-dialog.tsx` (new only if extraction improves the existing page) — search, pagination/load-more, multi-select, connected/disabled states, loading/error/empty states, and import progress using existing UI primitives.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The picker supports GitHub and Forgejo/Gitea connection states returned by Brief 1 and gives actionable reauthorization/configuration messages.
|
||||
- Search matches full name and description without discarding selection when paging.
|
||||
- Already-connected repos cannot be selected.
|
||||
- `batchConnect` is atomic for valid selections, idempotent for duplicates, tenant-scoped, and returns per-repo connected/already-connected results.
|
||||
- A stale or forged client selection cannot connect an arbitrary URL.
|
||||
- Successful import invalidates `connectedRepos.list` and `listAvailable` and leaves the manual paste-URL dialog usable.
|
||||
- Lint and type-check pass; cover server-side validation and selection-state helpers with tests where feasible.
|
||||
|
||||
## Prompt for implementer
|
||||
|
||||
Implement only Brief 2 after Brief 1 is complete. Read the matching backlog task and existing repo settings page before editing. Reuse Radix and `components/ui`; do not implement repository document ingestion or agent setup. Never send forge credentials to a client component.
|
||||
51
.convoys/repo-discovery-onboarding/brief-3-ingestion.md
Normal file
51
.convoys/repo-discovery-onboarding/brief-3-ingestion.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
---
|
||||
brief: 3
|
||||
convoy: repo-discovery-onboarding
|
||||
role: role-implementer
|
||||
title: Idempotent repository context ingestion
|
||||
status: ready
|
||||
priority: P1
|
||||
slice_dependencies: [1, 2]
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
workspace_id: global
|
||||
---
|
||||
|
||||
# Brief 3 — Idempotent repository context ingestion
|
||||
|
||||
## Goal
|
||||
|
||||
After repositories are connected, an explicit retryable ingestion action imports supported repository documentation and existing convoy manifests with provenance, without making remote API latency part of the batch-connect transaction.
|
||||
|
||||
Backlog task: `plans/Plan-automation-foundation/Epic-repo-discovery-onboarding/Task-repo-doc-ingestion.md`.
|
||||
|
||||
## Architecture constraints
|
||||
|
||||
- Use the canonical object type `document`, not `doc`.
|
||||
- Do not import an unbounded repository tree. V1 paths are root `README.md`, `AGENTS.md`, `CONTRIBUTING.md`, and `.convoys/*.md`; recursive `docs/**` and `plans/**` are deferred until limits and conflict semantics are designed.
|
||||
- Import is idempotent by `(workspace, connectedRepoId, sourcePath)`, and records source forge, repo, path, revision/sha, and last import result in metadata.
|
||||
- Keep remote content read-only. Re-import updates the matching imported object only when the remote revision changes.
|
||||
- Share convoy parsing/reconciliation logic from a neutral package; do not import `apps/mcp-server` source into `apps/web`.
|
||||
- A missing optional file is not an error. Authentication, rate-limit, parse, and persistence failures are visible per repo and retryable.
|
||||
|
||||
## Files
|
||||
|
||||
- Shared connected-repo schemas/services — add bounded tree/list-file and fetch-text-file contracts with size/type validation.
|
||||
- Forge adapters — implement GitHub and Forgejo/Gitea directory/file reads against the connected repo's default branch.
|
||||
- Extract reusable convoy markdown parsing from `apps/mcp-server/src/lib/convoy-files.ts` into a package usable by web and MCP, preserving local-file behavior.
|
||||
- Database schema/migration if needed — add durable repository-source identity and ingestion status. Every new tenant row includes `workspace_id` and an index.
|
||||
- Web tRPC/service — add admin-only `startImport`/`importStatus` or an equivalent bounded background execution path. Do not perform all remote reads inside the `batchConnect` transaction.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Importing a repo creates or updates `document` objects for supported files with source provenance.
|
||||
- Re-running an unchanged import creates no duplicates.
|
||||
- Existing `.convoys/*.md` manifests reconcile to workspace-scoped convoy projects. Brief files are imported only when their schema is valid and can be linked safely.
|
||||
- One bad file does not roll back successful files; the result reports imported, unchanged, skipped, and failed counts.
|
||||
- File count and byte limits prevent accidental large imports.
|
||||
- Unit/integration tests cover idempotency, missing files, malformed convoy frontmatter, tenant isolation, and partial failure.
|
||||
- Generate and commit a new migration for schema changes; lint, type-check, test, and relevant build checks pass.
|
||||
|
||||
## Prompt for implementer
|
||||
|
||||
Implement only Brief 3 after Briefs 1 and 2. Read `config/CursorSync.md` before changing sync behavior. Preserve the local-first `.convoys/` contract and never mutate remote files. Keep ingestion bounded and retryable; do not silently invent conflict resolution for `plans/**`.
|
||||
48
.convoys/repo-discovery-onboarding/brief-4-agent-mcp.md
Normal file
48
.convoys/repo-discovery-onboarding/brief-4-agent-mcp.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
brief: 4
|
||||
convoy: repo-discovery-onboarding
|
||||
role: role-implementer
|
||||
title: Agent onboarding and repository bootstrap
|
||||
status: ready
|
||||
priority: P2
|
||||
slice_dependencies: [2, 3]
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
workspace_id: global
|
||||
---
|
||||
|
||||
# Brief 4 — Agent onboarding and repository bootstrap
|
||||
|
||||
## Goal
|
||||
|
||||
After connection/import, the operator receives accurate, client-specific instructions for connecting an agent to Echodo and can optionally open a bootstrap PR for repository-side convoy guidance.
|
||||
|
||||
Backlog task: `plans/Plan-automation-foundation/Epic-repo-discovery-onboarding/Task-agent-mcp-starter.md`.
|
||||
|
||||
## Product and security contract
|
||||
|
||||
- Do not generate snippets containing `DATABASE_URL` or other infrastructure secrets.
|
||||
- Do not assume an `ECHODO_WORKSPACE_ID` environment variable is consumed unless the MCP transport/config actually supports it.
|
||||
- Support the deployment's real MCP transport first. If this remains local stdio, show the command plus non-secret workspace slug used in tool arguments. If remote authenticated MCP is added separately, link to that setup rather than fabricating it here.
|
||||
- Client formats differ. Render templates from typed server data for explicitly supported clients (OpenCode and Cursor first); label unsupported clients instead of presenting one generic JSON object as universal.
|
||||
- Any repository bootstrap change is a commit on a branch followed by `createPullRequest`; `createPullRequest` alone cannot create files.
|
||||
|
||||
## Files
|
||||
|
||||
- Add a typed onboarding-config procedure returning only non-secret deployment and workspace/repo identifiers.
|
||||
- Add an Agent setup panel to connected-repo settings with client tabs, copy controls, prerequisites, and verification steps.
|
||||
- Add bootstrap service only if the forge adapter has branch/file/commit primitives; otherwise scope this brief to guidance and create a follow-up task rather than simulating a PR.
|
||||
- Bootstrap content should reference the canonical `.convoys/README.md` contract and avoid overwriting an existing file without explicit confirmation.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- OpenCode and Cursor examples are syntactically valid for their actual configuration formats and contain no database URL, PAT, access token, or API-key value.
|
||||
- Instructions identify the selected workspace and repository and include a smoke test such as `query_manifest_status`.
|
||||
- Copy controls provide success/error feedback and work over secure browser contexts.
|
||||
- Bootstrap is idempotent: existing `.convoys/README.md` is detected, and any remote change is shown to the user through a PR URL.
|
||||
- Authorization is workspace owner/admin for remote writes; read-only members may view non-secret setup guidance if allowed by workspace policy.
|
||||
- Lint, type-check, and relevant UI/service tests pass.
|
||||
|
||||
## Prompt for implementer
|
||||
|
||||
Implement only Brief 4 after Briefs 2 and 3. First verify the MCP server's supported transport and environment contract in code. Never place infrastructure credentials in browser-rendered snippets. Do not claim a bootstrap PR is possible unless branch/file commit support is implemented and tested.
|
||||
124
.convoys/workspace-chat-foundation.md
Normal file
124
.convoys/workspace-chat-foundation.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
---
|
||||
name: workspace-chat-foundation
|
||||
classification: feature
|
||||
success_metric: Two workspace members can exchange durable channel messages, reconnect without loss, and open Chat from the left rail with accurate unread state.
|
||||
skip: []
|
||||
status: open
|
||||
created: 2026-09-25
|
||||
model_policy:
|
||||
roles:
|
||||
role-conductor: claude-planning
|
||||
role-architect: claude-planning
|
||||
role-implementer: qwen-coder
|
||||
role-reviewer: qwen-coder
|
||||
escalate_to: claude-planning
|
||||
---
|
||||
|
||||
# Workspace chat foundation
|
||||
|
||||
## Why
|
||||
|
||||
Echodo has collaborative documents, convoy operator notes, and AI chat surfaces, but no durable workspace conversation layer. The first slice must establish trustworthy persistence and realtime delivery before notifications or agent participation depend on it.
|
||||
|
||||
Canonical backlog: `plans/Plan-workspace-communications/Epic-workspace-chat/Epic-workspace-chat.md`.
|
||||
|
||||
## Scope
|
||||
|
||||
- Brief 1: tenant-scoped chat schema, channel lifecycle, message/outbox transaction, and workspace-authorized tRPC API.
|
||||
- Brief 2: outbox publication, authenticated WebSockets, Redis fan-out, and reconnect catch-up.
|
||||
- Brief 3: workspace Chat routes, channel/message UI, and left-rail unread/mention affordance.
|
||||
- Postgres is authoritative; Redis and WebSockets accelerate delivery.
|
||||
- Yjs/Hocuspocus may support presence/typing/drafts, but does not own message history.
|
||||
|
||||
Out of scope for this convoy: unified non-chat inbox, agent execution, external delivery, search, and federation.
|
||||
|
||||
## Role activity and kickoff — 2026-10-22
|
||||
|
||||
- User approved beginning the integrated contextual-chat/AI-agent direction; this does not authorize merge or release.
|
||||
- This session performed conductor reconciliation and architect schema/access/dependency review. IA, UX, implementation, review and release work are not claimed complete by the existing section headings.
|
||||
- At kickoff, Brief 1 was the next implementable slice. Superseded by the integration checkpoint below; the independent AI shell implementer remains tracked in the AI convoy.
|
||||
- Files are local planning artifacts. No convoy DB registration, task claim, event logging, or MCP gate transition was performed; those tools are unavailable here. `open` is the local README status, not a server lifecycle claim.
|
||||
|
||||
## Go-live reconciliation — 2026-10-29
|
||||
|
||||
- Operator reports Chat deployed on CT 116 and 14/14 checks passed, including two-member messaging, reply, reconnect, unread, edit/delete recovery and revocation. Canonical endpoint: `wss://chat.stillwell.cloud/chat`.
|
||||
- CT 102 ledger reported at 24 entries through `0023`; Chat schema verified and table ownership corrected to `echodo`. No migrations rerun here.
|
||||
- Deployment evidence is now present in `docs/Current Progress.md`; local channel projection/plain-JSON redaction fixes preserved with regression coverage. Ticket origin handling is hardened locally to configured auth URL rather than forwarded headers; requires rebuild and post-deploy retest.
|
||||
- Direct/PVE SSH trust in this harness remains unavailable, but is not a production rollout blocker. Operator production acceptance and local automated checks are separate evidence.
|
||||
- Shared credentials password removal needs an operator decision. Source-built web rebuild, full checks and final review remain open; no lifecycle transition or release gate completion is inferred from the deployment report.
|
||||
|
||||
## Earlier integration checkpoint — 2026-10-29 (superseded by go-live report)
|
||||
|
||||
- Local implementation now includes persistence/edit/tombstone transactions, mutation catch-up, signed-ticket subscriptions, Redis outbox publication, and workspace Chat UI with scoped recovery. Cursors are in-memory, not persisted across browser reloads.
|
||||
- Deployment wiring now extends `collab` with `CHAT_PORT` (default 1235), a shared runtime signing secret, dual-listener liveness checks, standalone publication, and a public TLS proxy example. The web URL is a build argument and Turbo build input.
|
||||
- Configuration regression tests cover the wiring; actual Docker Compose/image validation still requires a Docker host. See [current progress](../docs/Current%20Progress.md) and the [deployment gate](../docs/deployment-production.md#workspace-chat-integration-gate).
|
||||
- Production migrations `0022`/`0023` remain unverified. CT 116 SSH was blocked by a missing trusted host key; no remote changes were made. Disposable-Postgres, Redis/WebSocket, browser/revocation, accessibility and final review gates remain open.
|
||||
- Earlier commit/push completion claims were incorrect: the implementation was still uncommitted at inspection. No merge, release, or server lifecycle transition is claimed by this checkpoint.
|
||||
|
||||
## Full product versus this foundation
|
||||
|
||||
The approved product includes workspace, project and supported-object chat plus mention-to-add agents. This convoy implements **workspace-context human chat only**, with an explicit fail-closed context policy seam. Object contexts must be rejected until the canonical follow-on [context authorization task](../plans/Plan-workspace-communications/Epic-workspace-chat/Task-contextual-chat-authorization.md) lands; do not treat a same-workspace object lookup as an ACL. Mention-to-add belongs to [agent channel membership](../plans/Plan-workspace-communications/Epic-agent-participation/Task-agent-channel-membership.md), not these three briefs.
|
||||
|
||||
All briefs follow the [plan's context and agent permission contracts](../plans/Plan-workspace-communications/Plan-workspace-communications.md). Projects are generic `objects` rows; the inspected schema has no private-object authorization model. This gap is a blocker for contextual rollout, not a reason to silently widen access.
|
||||
|
||||
## Todos
|
||||
|
||||
- [x] User approved kickoff of the integrated direction on 2026-10-22; merge/release gates remain pending.
|
||||
- [ ] Implement brief 1 and validate tenant isolation and idempotency.
|
||||
- [ ] Implement brief 2 and validate outage/reconnect behavior.
|
||||
- [ ] Implement brief 3 and validate accessibility and multi-device unread state.
|
||||
- [ ] Reviewer checks standards and the three linked backlog tasks.
|
||||
- [ ] Human performs merge and release gates.
|
||||
|
||||
## IA
|
||||
|
||||
- Add `Chat` as a first-class left-rail destination within the current workspace.
|
||||
- `/[workspaceSlug]/chat` selects or redirects to a channel.
|
||||
- `/[workspaceSlug]/chat/[channelSlug]` renders channel navigation and conversation.
|
||||
- Threads open in the existing right-panel pattern where practical, preserving channel context.
|
||||
- The global notification inbox is a later epic; Chat still exposes its own durable unread and mention counts.
|
||||
|
||||
## UX
|
||||
|
||||
- Ordinary unread activity uses a dot; mentions/direct messages use a numeric badge.
|
||||
- Badges include accessible labels and never rely on color alone.
|
||||
- Optimistic messages show pending, failed, and retry states.
|
||||
- Reconnect and catch-up are silent when successful and explicit when recovery fails.
|
||||
- Keyboard navigation covers rail entry, channel switching, message history, composer, and thread opening.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data and command path
|
||||
|
||||
1. A workspace-authorized tRPC mutation validates the command and channel membership.
|
||||
2. Brief 1 owns one Postgres transaction that inserts the message and its versioned outbox event.
|
||||
3. Brief 2 owns an outbox worker that claims and publishes committed events through Redis.
|
||||
4. Realtime instances fan events out to authenticated local WebSocket subscribers.
|
||||
5. Reconnecting clients query Postgres after their last durable sequence cursor.
|
||||
|
||||
### Boundaries
|
||||
|
||||
- `packages/database`: tenant-scoped chat and outbox schema plus migration.
|
||||
- `packages/shared`: zod contracts and versioned realtime event types when shared across processes.
|
||||
- `apps/web`: workspace-authorized tRPC router, routes, React Query state, and shell navigation.
|
||||
- `apps/collab-server` or a narrowly separated realtime entry point: authenticated subscriptions and Redis fan-out.
|
||||
- Existing Hocuspocus/Yjs infrastructure remains available for ephemeral presence, typing, and collaborative drafts.
|
||||
|
||||
### Security and scale
|
||||
|
||||
- Every query enforces workspace AND context AND channel access. Foundation accepts workspace context only; public workspace channels require active workspace membership, private/direct also require channel membership, and archived channels are read-only. Channel owners or workspace admins/owners administer only channels they may access; role alone grants no private/direct read or self-join bypass. Object contexts require a separate verified policy.
|
||||
- Brief 1 defines the versioned outbox contract and owns the atomic message/outbox transaction; Brief 2 owns claim, publication, retry, and consumer delivery.
|
||||
- Message commands accept client idempotency keys and generate a per-channel monotonic sequence; reconnect cursors are `(channel_id, sequence)` pairs.
|
||||
- Realtime events carry identifiers and cursors, not authorization truth; subscription and catch-up endpoints authorize independently.
|
||||
- Redis failure cannot roll back a committed message. Outbox retries and Postgres catch-up restore delivery.
|
||||
- Use bounded pagination and backpressure; do not broadcast entire message histories.
|
||||
- This convoy supports one-level threads in the persistence/API contract; full thread-panel polish may remain minimal in Brief 3.
|
||||
- Read state is the highest message sequence the member has intentionally viewed. In this convoy, mention counts are derived from validated persisted message-mention rows above that cursor; the notification epic later projects those mentions into the unified inbox. Mark-read emits an invalidation event for other devices.
|
||||
|
||||
## Briefs
|
||||
|
||||
| Brief | Backlog task | Depends on |
|
||||
| --- | --- | --- |
|
||||
| 1 | `Task-chat-persistence.md` | none |
|
||||
| 2 | `Task-chat-realtime-delivery.md` | brief 1 |
|
||||
| 3 | `Task-chat-ui-and-navigation.md` | briefs 1 and 2 |
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
---
|
||||
brief: 1
|
||||
convoy: workspace-chat-foundation
|
||||
role: role-implementer
|
||||
title: Durable chat persistence and workspace API
|
||||
status: in_progress
|
||||
priority: P1
|
||||
slice_dependencies: []
|
||||
recommended_model: auto/coding
|
||||
model_tier: standard
|
||||
---
|
||||
|
||||
# Brief 1 — `Task-chat-persistence.md`
|
||||
|
||||
## Execution checkpoint — 2026-10-22
|
||||
|
||||
- Shared command/context validation and the fail-closed authorization policy are implemented and accepted after standards/spec review; `@tasks/shared` has 65 passing tests.
|
||||
- The tenant-scoped chat schema and generated migration are implemented and accepted after standards/spec/migration review; database tests pass. A separate generated `0018` repairs pre-existing omitted budget tables, and chat is isolated in generated migration `0019`.
|
||||
- The concrete Drizzle human send/reply transaction, mention-aware idempotency, atomic sequence allocation, validated `chat.message.created.v1` outbox event, and workspace-authorized `chat.send` tRPC procedure are implemented and review-clean. Acceptance remains blocked on the live PostgreSQL integration gate below; channel lifecycle, history, edit/tombstone, mention counts, and read-state operations are pending.
|
||||
- **Blocking integration gate:** against disposable PostgreSQL with migrations through `0019`, verify advisory-lock blocking under simultaneous same-key sends; exactly one message, mention set, logical outbox event, and sequence increment; full rollback of the sequence/message/mentions/outbox after an injected post-allocation failure; and Drizzle's runtime `bigint` shape for `UPDATE … RETURNING next_sequence - 1`. Unit fakes verify orchestration only and must not be cited as transaction/concurrency proof.
|
||||
- IA/UX review completed; private-object authorization remains blocked, and agent approval semantics and AI side-panel convergence require resolution before their dependent integrations ship.
|
||||
|
||||
## Goal
|
||||
|
||||
Implement the durable, tenant-scoped channel/message foundation and workspace-authorized tRPC API described by `plans/Plan-workspace-communications/Epic-workspace-chat/Task-chat-persistence.md`.
|
||||
|
||||
## Files
|
||||
|
||||
Expected scope; confirm exact module names against current package exports before editing.
|
||||
|
||||
- `packages/database/src/schema/` — add chat channel, membership, message, read-state, and the outbox contract schemas and export them.
|
||||
- `packages/database/migrations/` — generate a new migration and metadata; never edit an existing migration.
|
||||
- `packages/shared/src/` — add reusable zod schemas/types for chat commands, author/message states, and the versioned outbox event where cross-process sharing is needed.
|
||||
- `apps/web/server/routers/` — add a `workspaceProcedure` chat router for channel list/create/archive, membership management, history/send/reply/edit/delete, and mark-read.
|
||||
- Adjacent `*.test.ts` files — authorization, provisioning, pagination, idempotency, thread, and cross-workspace isolation coverage.
|
||||
|
||||
## Required design
|
||||
|
||||
- Every tenant-owned table has non-null indexed `workspace_id`.
|
||||
- Channel types are `public`, `private`, and `direct`; visibility is separate from context. This brief accepts only an explicit workspace context (including a documented workspace default for existing callers); reject object references/unknown context keys rather than dropping them. Add a shared authorization seam with workspace AND context AND channel checks; do not implement a project ACL in this brief.
|
||||
- Public workspace channels are readable by active workspace members; private/direct channels additionally require active channel membership. Every list/title/preview/history/thread/mention/count/read-state query uses the same access predicate. Archived channels permit authorized reads only.
|
||||
- Active members create workspace public/private channels and become channel owner. An authorized channel owner or workspace `admin`/`owner` can manage that channel only with channel access; role alone is not a private/direct read bypass or self-join mechanism. All membership targets must themselves be authorized. Direct-channel participant sets are immutable; changes resolve a different exact-participant channel. Object/private-project access remains blocked until an authoritative context policy exists.
|
||||
- Message authors may edit their own non-deleted messages. They may tombstone their own messages; channel owners and workspace `admin`/`owner` may tombstone messages for moderation. System/agent edits require the owning service path, not an impersonated user mutation.
|
||||
- Channel lifecycle includes public/private creation, rename/archive, private membership management, and idempotent direct-channel creation for an exact sorted participant set.
|
||||
- Message authors use a discriminated user/agent/system representation without fake user rows.
|
||||
- A client-generated idempotency key is unique within the workspace, channel, and author scope.
|
||||
- Every message receives a transactionally allocated, per-channel monotonic sequence. History and reconnect cursors are `(channel_id, sequence)`; message ID breaks ties only if allocation semantics ever permit one.
|
||||
- Sending a message or thread reply inserts its versioned outbox event in the same transaction. Brief 1 owns this boundary; Brief 2 consumes the outbox.
|
||||
- Threads are one level. Channel history returns root messages only plus reply counts/latest-reply metadata; thread history returns direct replies ordered by the same channel sequence; replies cannot themselves receive replies. Every root reference must resolve inside the same workspace and channel.
|
||||
- Deletes are tombstones where thread/history integrity requires them.
|
||||
- Read state stores the highest intentionally viewed message sequence per member/channel. `markChannelRead` may only advance it and inserts a read-state invalidation event for other devices.
|
||||
- Mention parsing creates validated, workspace-scoped persisted message-mention rows in the message transaction. This convoy derives unread mention counts from those rows above the recipient's read cursor; the notifications epic later creates inbox projections from the same source events.
|
||||
- All reads and mutations authorize through `ctx.workspace` plus the channel-type and role rules above.
|
||||
|
||||
## Scope checkpoint and acceptance
|
||||
|
||||
Next implementable chat brief: this one. Start with failing policy tests and schema contracts, then migration and workspace-only router/outbox. Existing `objects` includes projects but has no private-object ACL: do not invent project memberships or authorize by `workspace_id` alone. See [context follow-on](../../plans/Plan-workspace-communications/Epic-workspace-chat/Task-contextual-chat-authorization.md).
|
||||
|
||||
- Same-workspace unsupported object contexts fail closed; no fallback public channel is created.
|
||||
- Nonmember workspace admins cannot read/self-add to private/direct channels via administrative endpoints; counts/previews cannot reveal them.
|
||||
- Access removal is respected by subsequent queries/writes; stale membership cannot override context denial.
|
||||
- Foundation mention parsing cannot install/add/invoke agents; the agent-channel-membership task owns reviewed addition.
|
||||
|
||||
- Duplicate send retries result in one persisted message.
|
||||
- Cross-workspace and unauthorized private-channel requests return non-disclosing errors.
|
||||
- Stable cursor pagination works for forward catch-up and backward history.
|
||||
- Channel creation, rename/archive, private membership changes, immutable exact-participant direct channels, and message edit/moderation enforce the documented ownership and workspace-role rules.
|
||||
- One-level thread roots/replies, persisted mention rows/counts, and durable per-member read state have working API coverage.
|
||||
- `markChannelRead` only advances the per-channel sequence and atomically emits its invalidation event.
|
||||
- Message and thread-reply commits atomically include the corresponding outbox event.
|
||||
- `pnpm db:generate`, `pnpm lint`, `pnpm type-check`, and relevant tests pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
Read `AGENTS.md`, `docs/Glossary.md`, database schema exports, `apps/web/server/trpc.ts`, and `apps/web/server/lib/resolve-workspace.ts` before writing. Use TDD for authorization and idempotency. Keep this brief limited to durable persistence/API and atomic outbox writes; do not implement outbox publication, WebSockets, or UI.
|
||||
55
.convoys/workspace-chat-foundation/brief-2-chat-realtime.md
Normal file
55
.convoys/workspace-chat-foundation/brief-2-chat-realtime.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
brief: 2
|
||||
convoy: workspace-chat-foundation
|
||||
role: role-implementer
|
||||
title: Transactional outbox and realtime chat delivery
|
||||
status: blocked
|
||||
priority: P1
|
||||
slice_dependencies:
|
||||
- brief-1-chat-persistence
|
||||
recommended_model: auto/coding
|
||||
model_tier: standard
|
||||
---
|
||||
|
||||
# Brief 2 — `Task-chat-realtime-delivery.md`
|
||||
|
||||
## Goal
|
||||
|
||||
Deliver committed chat events to authenticated subscribers across instances while preserving Postgres replay as the recovery path.
|
||||
|
||||
## Files
|
||||
|
||||
Expected scope; refine after brief 1 lands.
|
||||
|
||||
- `packages/database/src/schema/` — consume the outbox and per-channel sequence contracts from brief 1; add a generated migration only if claim/retry state genuinely requires a schema extension.
|
||||
- `packages/shared/src/` — consume or refine the versioned chat event envelopes and `(channel_id, sequence)` cursor schemas from brief 1 without creating a second contract.
|
||||
- `apps/collab-server/src/index.ts` and focused modules — authenticated WebSocket subscriptions and Redis fan-out.
|
||||
- `apps/worker/src/` or the established background-job boundary — claim/publish/retry outbox records.
|
||||
- `apps/web/` realtime client integration — authenticated connection and catch-up trigger only; visual chat UI belongs to brief 3.
|
||||
- Adjacent tests for authorization, retries, ordering, reconnect, and Redis outage behavior.
|
||||
|
||||
## Required design
|
||||
|
||||
- Commands remain HTTP/tRPC; do not introduce browser gRPC.
|
||||
- Brief 1 already commits message/read-state rows and outbox events atomically; this brief must preserve that boundary and must not publish before commit.
|
||||
- Publishers are at-least-once; consumers and clients handle duplicates by outbox event ID and durable entity ID.
|
||||
- Redis carries cross-instance fan-out, not canonical history.
|
||||
- Subscriptions and replay use Brief 1's workspace AND context AND channel authorization, never workspace membership alone. Public is relative to the authorized context; private/direct also require membership. Archived channels allow authorized read/catch-up only. Recheck on delivery and permission changes, close revoked subscriptions and stop fan-out; a Redis topic or cached subscription is not authority. Object contexts remain rejected until the context-policy follow-on.
|
||||
- Each subscribed channel tracks its own `(channel_id, sequence)` cursor. Reconnect catch-up queries Postgres independently for each authorized channel; no workspace-global ordering is implied.
|
||||
- Message, thread-reply, tombstone, channel lifecycle, membership, and read-state events use one versioned envelope. `markChannelRead` invalidations update badges on the user's other devices.
|
||||
- Reuse Hocuspocus/Yjs only for ephemeral presence, typing, or drafts.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A committed message remains queryable and is eventually delivered after Redis recovers.
|
||||
- Two realtime instances fan the same event to their local authorized subscribers.
|
||||
- Duplicate outbox publishes do not duplicate rendered durable messages or read-state changes.
|
||||
- Multi-channel reconnect catches each channel up from its own sequence without assuming cross-channel ordering.
|
||||
- Public, private, direct, and archived subscription rules match Brief 1 and unauthorized attempts disclose no data.
|
||||
- A read-state event clears or recalculates unread state on another connected device.
|
||||
- Heartbeat, bounded buffers/backpressure, graceful shutdown, and reconnect behavior are covered.
|
||||
- `pnpm lint && pnpm type-check && pnpm test && pnpm build` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
Read `config/CursorSync.md` for collab-server lifecycle, the current Hocuspocus/Redis configuration, and brief 1's resulting schema/API. Keep durable messages out of Yjs. Document any deploy-impacting port or process changes explicitly.
|
||||
55
.convoys/workspace-chat-foundation/brief-3-chat-ui.md
Normal file
55
.convoys/workspace-chat-foundation/brief-3-chat-ui.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
brief: 3
|
||||
convoy: workspace-chat-foundation
|
||||
role: role-implementer
|
||||
title: Chat workspace UI and left-rail navigation
|
||||
status: blocked
|
||||
priority: P1
|
||||
slice_dependencies:
|
||||
- brief-1-chat-persistence
|
||||
- brief-2-chat-realtime
|
||||
recommended_model: auto/coding
|
||||
model_tier: standard
|
||||
---
|
||||
|
||||
# Brief 3 — `Task-chat-ui-and-navigation.md`
|
||||
|
||||
## Goal
|
||||
|
||||
Expose workspace chat through the existing application shell with accessible channel navigation, durable history/read state, and realtime updates.
|
||||
|
||||
## Files
|
||||
|
||||
Expected scope; confirm conventions against nearby workspace routes and UI components.
|
||||
|
||||
- `apps/web/app/(app)/[workspaceSlug]/chat/page.tsx` — channel selection/redirect or empty state.
|
||||
- `apps/web/app/(app)/[workspaceSlug]/chat/[channelSlug]/page.tsx` — channel conversation route.
|
||||
- `apps/web/components/layout/icon-rail.tsx` — Chat destination and accessible unread/mention badge.
|
||||
- `apps/web/components/chat/` — channel list, paginated message feed, composer, thread affordance, connection state, and retry UI.
|
||||
- `apps/web/components/providers/` or focused hooks — realtime event reconciliation with tRPC/React Query caches.
|
||||
- Adjacent component/unit tests.
|
||||
|
||||
## Required design
|
||||
|
||||
- Server state is authoritative; realtime events invalidate or reconcile durable query data. Foundation UI supports workspace-context channels only; project/object entry points wait for the context authorization task. Never expose unauthorized titles/previews, mention candidates or counts; purge inaccessible cached conversations on denial/revocation. Mention UI must not promise agent addition/execution before its reviewed membership flow exists.
|
||||
- Ordinary unread activity uses a dot. In this convoy, the numeric badge is computed from persisted message-mention rows above each channel read cursor plus unread direct-channel activity. The later notifications epic adds unified inbox projections without changing the rail contract.
|
||||
- Unread means a channel's highest message sequence is greater than the member's last-read sequence. Opening a channel does not mark unseen content read until the client intentionally advances the visible boundary.
|
||||
- Direct-channel unread activity contributes to the numeric Chat badge; ordinary public/private channel activity contributes only the unread dot unless the user is mentioned.
|
||||
- Optimistic messages and thread replies have deterministic pending, committed, failed, and retry states keyed by the idempotency key.
|
||||
- History and thread history use per-channel sequence cursor pagination and preserve scroll position while loading older messages.
|
||||
- Realtime read-state invalidations reconcile rail/channel badges across the user's devices.
|
||||
- Keyboard and screen-reader behavior cover navigation, unread indicators, composer, threads, and failures.
|
||||
- Use existing Radix/Tailwind primitives and the current right-panel pattern for a minimally complete thread list/reply surface.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A user can enter Chat from the left rail, switch channels, send/retry messages and thread replies, intentionally advance the read boundary, and reload without losing state.
|
||||
- Realtime messages and replies appear once and per-channel reconnect catch-up converges to server truth.
|
||||
- Rail/channel unread, direct-message, and mention indicators update across devices after durable mark-read operations.
|
||||
- Loading, empty, unauthorized, archived, offline, and failed-send states are handled.
|
||||
- Responsive layout and keyboard navigation work without inaccessible color-only signals.
|
||||
- `pnpm lint && pnpm type-check && pnpm test` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
Read the workspace layout, icon rail, right panel, UI primitives, tRPC provider, and the APIs/events produced by briefs 1–2. Do not add a separate design system or raw internal fetch calls. Keep notification inbox and agent participation out of this slice.
|
||||
|
|
@ -171,4 +171,13 @@ POSTGRES_DB=echodo
|
|||
POSTGRES_PORT=5432
|
||||
REDIS_PORT=6379
|
||||
WEB_PORT=3000
|
||||
# ----- Chat (durable channel messaging) -----
|
||||
# Shared secret for signing short-lived subscription tickets. Must match on web and collab.
|
||||
# Generate at least 32 random bytes, for example: openssl rand -hex 32
|
||||
CHAT_TICKET_SECRET="CHANGE_ME_USE_A_RANDOM_32_BYTE_SECRET"
|
||||
# Internal collab chat listener port (distinct from Hocuspocus/Yjs on 1234)
|
||||
CHAT_PORT=1235
|
||||
# Public WSS endpoint, baked into the web bundle at build time. Rebuild web after changing.
|
||||
NEXT_PUBLIC_CHAT_SERVER_URL="wss://chat.stillwell.cloud"
|
||||
|
||||
COLLAB_PORT=1234
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsup",
|
||||
"start": "node dist/index.mjs",
|
||||
"type-check": "tsc --noEmit"
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hocuspocus/common": "^2.15.0",
|
||||
|
|
@ -16,12 +17,15 @@
|
|||
"@tasks/database": "workspace:*",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"ioredis": "^5.4.2",
|
||||
"ws": "^8.20.0",
|
||||
"yjs": "^13.6.22",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.18.2",
|
||||
"tsup": "^8.3.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.0"
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
86
apps/collab-server/src/chat.test.ts
Normal file
86
apps/collab-server/src/chat.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createHmac } from "node:crypto";
|
||||
import { authorizeChannel, parseTopic, verifyTicket } from "./chat.js";
|
||||
|
||||
const workspaceId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const channelId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
const userId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
||||
const secret = "a".repeat(40);
|
||||
const claims = {
|
||||
iss: "echodo-web",
|
||||
aud: "echodo-chat-v1",
|
||||
scope: "chat:subscribe",
|
||||
sub: userId,
|
||||
workspaceId,
|
||||
exp: Math.floor(Date.now() / 1000) + 30,
|
||||
};
|
||||
function ticket(payload: object, key = secret) {
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
return `${body}.${createHmac("sha256", key).update(body).digest("base64url")}`;
|
||||
}
|
||||
|
||||
describe("chat ticket", () => {
|
||||
it("rejects unsigned, altered, expired and wrong-scope tickets", () => {
|
||||
expect(verifyTicket(ticket(claims), secret)).toEqual(claims);
|
||||
expect(verifyTicket(ticket(claims).replace(/.$/, "x"), secret)).toBeNull();
|
||||
expect(verifyTicket(ticket({ ...claims, exp: 1 }), secret)).toBeNull();
|
||||
expect(
|
||||
verifyTicket(ticket({ ...claims, scope: "yjs" }), secret),
|
||||
).toBeNull();
|
||||
expect(
|
||||
verifyTicket(ticket({ ...claims, aud: "other" }), secret),
|
||||
).toBeNull();
|
||||
expect(verifyTicket(ticket(claims, "b".repeat(40)), secret)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("topic and authorization", () => {
|
||||
it("accepts only exact workspace and channel UUID topics", () => {
|
||||
expect(
|
||||
parseTopic(`chat:v1:workspace:${workspaceId}:channel:${channelId}`),
|
||||
).toEqual({ workspaceId, channelId });
|
||||
expect(
|
||||
parseTopic(`chat:v1:workspace:${workspaceId}:channel:${channelId}:evil`),
|
||||
).toBeNull();
|
||||
});
|
||||
it("requires workspace membership, and channel membership for private/direct (no admin bypass)", async () => {
|
||||
const lookup = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
kind: "public",
|
||||
workspaceMember: true,
|
||||
channelMember: false,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
kind: "private",
|
||||
workspaceMember: true,
|
||||
channelMember: false,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
kind: "direct",
|
||||
workspaceMember: true,
|
||||
channelMember: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
kind: "public",
|
||||
workspaceMember: false,
|
||||
channelMember: true,
|
||||
})
|
||||
.mockRejectedValueOnce(new Error("db down"));
|
||||
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
400
apps/collab-server/src/chat.ts
Normal file
400
apps/collab-server/src/chat.ts
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import IoRedis from "ioredis";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import { z } from "zod";
|
||||
import { db } from "@tasks/database/client";
|
||||
import {
|
||||
chatChannelMembers,
|
||||
chatChannels,
|
||||
workspaceMembers,
|
||||
} from "@tasks/database/schema";
|
||||
|
||||
const uuid = z.string().uuid();
|
||||
const ticketClaims = z
|
||||
.object({
|
||||
iss: z.literal("echodo-web"),
|
||||
aud: z.literal("echodo-chat-v1"),
|
||||
scope: z.literal("chat:subscribe"),
|
||||
sub: uuid,
|
||||
workspaceId: uuid,
|
||||
exp: z.number().int().positive(),
|
||||
})
|
||||
.strict();
|
||||
export type ChatTicket = z.infer<typeof ticketClaims>;
|
||||
const topicPattern =
|
||||
/^chat:v1:workspace:([0-9a-f-]{36}):channel:([0-9a-f-]{36})$/;
|
||||
const subscribe = z
|
||||
.object({ type: z.literal("subscribe"), channelId: uuid })
|
||||
.strict();
|
||||
const unsubscribe = z
|
||||
.object({ type: z.literal("unsubscribe"), channelId: uuid })
|
||||
.strict();
|
||||
const clientMessage = z.discriminatedUnion("type", [subscribe, unsubscribe]);
|
||||
const MAX_CONNECTIONS = 2000;
|
||||
const MAX_CHANNELS = 64;
|
||||
const MAX_MESSAGE_BYTES = 512;
|
||||
const MAX_BUFFERED_BYTES = 64 * 1024;
|
||||
const HEARTBEAT_MS = 15_000;
|
||||
const AUTH_TIMEOUT_MS = 5_000;
|
||||
|
||||
export function verifyTicket(value: string, secret: string): ChatTicket | null {
|
||||
if (secret.length < 32 || value.length > 2048) return null;
|
||||
const parts = value.split(".");
|
||||
if (
|
||||
parts.length !== 2 ||
|
||||
!parts[0] ||
|
||||
!/^[A-Za-z0-9_-]+$/.test(parts[0]) ||
|
||||
!/^[A-Za-z0-9_-]+$/.test(parts[1])
|
||||
)
|
||||
return null;
|
||||
const expected = createHmac("sha256", secret).update(parts[0]).digest();
|
||||
const supplied = Buffer.from(parts[1], "base64url");
|
||||
if (
|
||||
supplied.length !== expected.length ||
|
||||
!timingSafeEqual(supplied, expected)
|
||||
)
|
||||
return null;
|
||||
try {
|
||||
const parsed = ticketClaims.safeParse(
|
||||
JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8")),
|
||||
);
|
||||
if (
|
||||
!parsed.success ||
|
||||
parsed.data.exp <= Math.floor(Date.now() / 1000) ||
|
||||
parsed.data.exp > Math.floor(Date.now() / 1000) + 60
|
||||
)
|
||||
return null;
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTopic(
|
||||
topic: string,
|
||||
): { workspaceId: string; channelId: string } | null {
|
||||
const match = topicPattern.exec(topic);
|
||||
if (
|
||||
!match ||
|
||||
!uuid.safeParse(match[1]).success ||
|
||||
!uuid.safeParse(match[2]).success
|
||||
)
|
||||
return null;
|
||||
return { workspaceId: match[1], channelId: match[2] };
|
||||
}
|
||||
|
||||
type Lookup = (
|
||||
workspaceId: string,
|
||||
channelId: string,
|
||||
userId: string,
|
||||
) => Promise<{
|
||||
kind: string;
|
||||
workspaceMember: boolean;
|
||||
channelMember: boolean;
|
||||
} | null>;
|
||||
export async function authorizeChannel(
|
||||
lookup: Lookup,
|
||||
workspaceId: string,
|
||||
channelId: string,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const row = await Promise.race([
|
||||
lookup(workspaceId, channelId, userId),
|
||||
new Promise<null>((resolve) => {
|
||||
timeout = setTimeout(() => resolve(null), AUTH_TIMEOUT_MS);
|
||||
}),
|
||||
]).finally(() => clearTimeout(timeout));
|
||||
return (
|
||||
!!row &&
|
||||
row.workspaceMember &&
|
||||
(row.kind === "public" ||
|
||||
((row.kind === "private" || row.kind === "direct") &&
|
||||
row.channelMember))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Always query current membership; never cache authorization across notifications.
|
||||
const lookupChannel: Lookup = async (workspaceId, channelId, userId) => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
kind: chatChannels.kind,
|
||||
workspaceMember: workspaceMembers.userId,
|
||||
channelMember: chatChannelMembers.userId,
|
||||
})
|
||||
.from(chatChannels)
|
||||
.leftJoin(
|
||||
workspaceMembers,
|
||||
and(
|
||||
eq(workspaceMembers.workspaceId, chatChannels.workspaceId),
|
||||
eq(workspaceMembers.userId, userId),
|
||||
),
|
||||
)
|
||||
.leftJoin(
|
||||
chatChannelMembers,
|
||||
and(
|
||||
eq(chatChannelMembers.workspaceId, chatChannels.workspaceId),
|
||||
eq(chatChannelMembers.channelId, chatChannels.id),
|
||||
eq(chatChannelMembers.userId, userId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatChannels.workspaceId, workspaceId),
|
||||
eq(chatChannels.id, channelId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row
|
||||
? {
|
||||
kind: row.kind,
|
||||
workspaceMember: !!row.workspaceMember,
|
||||
channelMember: !!row.channelMember,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
type Peer = {
|
||||
socket: WebSocket;
|
||||
ticket: ChatTicket;
|
||||
channels: Set<string>;
|
||||
busy: boolean;
|
||||
};
|
||||
|
||||
/** Separate port: Hocuspocus owns its existing HTTP/WebSocket upgrade handler. */
|
||||
export async function startChatServer(): Promise<void> {
|
||||
const secret = process.env.CHAT_TICKET_SECRET?.trim();
|
||||
const redisUrl = process.env.REDIS_URL?.trim();
|
||||
if (!secret || secret.length < 32 || !redisUrl) {
|
||||
console.warn(
|
||||
"[chat] disabled: CHAT_TICKET_SECRET (>=32 characters) and REDIS_URL required",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const port = Number(process.env.CHAT_PORT ?? 1235);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
||||
throw new Error("Invalid CHAT_PORT");
|
||||
const subscriber = new IoRedis(redisUrl, { maxRetriesPerRequest: 1 });
|
||||
await subscriber.ping(); // Fail closed: never accept subscribers without Redis.
|
||||
const http = createServer((_req, res) => {
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
const wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: MAX_MESSAGE_BYTES,
|
||||
perMessageDeflate: false,
|
||||
});
|
||||
const peers = new Set<Peer>();
|
||||
const topicPeers = new Map<string, Set<Peer>>();
|
||||
const send = (peer: Peer, value: object) => {
|
||||
if (peer.socket.readyState !== 1) return;
|
||||
if (peer.socket.bufferedAmount > MAX_BUFFERED_BYTES) {
|
||||
peer.socket.close(1013);
|
||||
return;
|
||||
}
|
||||
peer.socket.send(JSON.stringify(value));
|
||||
};
|
||||
const remove = (peer: Peer, topic: string) => {
|
||||
peer.channels.delete(topic);
|
||||
const set = topicPeers.get(topic);
|
||||
set?.delete(peer);
|
||||
if (set?.size === 0) {
|
||||
topicPeers.delete(topic);
|
||||
void subscriber.unsubscribe(topic).catch(() => {});
|
||||
}
|
||||
};
|
||||
const revoke = (peer: Peer, topic: string, channelId: string) => {
|
||||
remove(peer, topic);
|
||||
send(peer, { type: "revoked", channelId });
|
||||
};
|
||||
const deliver = async (topic: string) => {
|
||||
const ids = parseTopic(topic);
|
||||
if (!ids) return;
|
||||
for (const peer of [...(topicPeers.get(topic) ?? [])]) {
|
||||
if (!peer.channels.has(topic)) continue;
|
||||
if (
|
||||
peer.ticket.exp <= Date.now() / 1000 ||
|
||||
!(await authorizeChannel(
|
||||
lookupChannel,
|
||||
ids.workspaceId,
|
||||
ids.channelId,
|
||||
peer.ticket.sub,
|
||||
))
|
||||
) {
|
||||
revoke(peer, topic, ids.channelId);
|
||||
peer.socket.close(1008);
|
||||
continue;
|
||||
}
|
||||
// Deliberately ignore Redis envelope including message bodies and keys.
|
||||
if (peer.channels.has(topic))
|
||||
send(peer, { type: "invalidate", channelId: ids.channelId });
|
||||
}
|
||||
};
|
||||
subscriber.on("message", (topic) => {
|
||||
void deliver(topic).catch(() => {});
|
||||
});
|
||||
subscriber.on("error", () => {
|
||||
for (const peer of peers) peer.socket.close(1011);
|
||||
});
|
||||
subscriber.on("end", () => {
|
||||
for (const peer of peers) peer.socket.close(1011);
|
||||
});
|
||||
http.on("upgrade", (req, socket, head) => {
|
||||
if (
|
||||
peers.size >= MAX_CONNECTIONS ||
|
||||
req.url?.length === undefined ||
|
||||
req.url.length > 2300
|
||||
) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
let token: string | null;
|
||||
try {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
token = url.pathname === "/chat" ? url.searchParams.get("ticket") : null;
|
||||
} catch {
|
||||
token = null;
|
||||
}
|
||||
const ticket = token ? verifyTicket(token, secret) : null;
|
||||
if (!ticket) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) =>
|
||||
wss.emit("connection", ws, ticket),
|
||||
);
|
||||
});
|
||||
wss.on("connection", (socket: WebSocket, ticket: ChatTicket) => {
|
||||
const peer: Peer = { socket, ticket, channels: new Set(), busy: false };
|
||||
peers.add(peer);
|
||||
const expiry = setTimeout(
|
||||
() => socket.close(1008),
|
||||
Math.max(0, ticket.exp * 1000 - Date.now()),
|
||||
);
|
||||
expiry.unref();
|
||||
socket.on("close", () => {
|
||||
clearTimeout(expiry);
|
||||
peers.delete(peer);
|
||||
for (const topic of [...peer.channels]) remove(peer, topic);
|
||||
});
|
||||
socket.on("message", (bytes) => {
|
||||
if (peer.busy) {
|
||||
socket.close(1008);
|
||||
return;
|
||||
}
|
||||
peer.busy = true;
|
||||
void (async () => {
|
||||
if (ticket.exp <= Date.now() / 1000) {
|
||||
socket.close(1008);
|
||||
return;
|
||||
}
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(bytes.toString());
|
||||
} catch {
|
||||
socket.close(1008);
|
||||
return;
|
||||
}
|
||||
const parsed = clientMessage.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
socket.close(1008);
|
||||
return;
|
||||
}
|
||||
const { channelId } = parsed.data;
|
||||
const topic = `chat:v1:workspace:${ticket.workspaceId}:channel:${channelId}`;
|
||||
if (parsed.data.type === "unsubscribe") {
|
||||
remove(peer, topic);
|
||||
return;
|
||||
}
|
||||
if (peer.channels.size >= MAX_CHANNELS && !peer.channels.has(topic)) {
|
||||
socket.close(1008);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!(await authorizeChannel(
|
||||
lookupChannel,
|
||||
ticket.workspaceId,
|
||||
channelId,
|
||||
ticket.sub,
|
||||
))
|
||||
) {
|
||||
socket.close(1008);
|
||||
return;
|
||||
}
|
||||
if (peer.channels.has(topic)) return;
|
||||
if (!topicPeers.has(topic)) {
|
||||
try {
|
||||
await subscriber.subscribe(topic);
|
||||
} catch {
|
||||
socket.close(1011);
|
||||
return;
|
||||
}
|
||||
if (!topicPeers.has(topic)) topicPeers.set(topic, new Set());
|
||||
}
|
||||
// Recheck after the Redis subscribe await: access may have been revoked meanwhile.
|
||||
if (
|
||||
!(await authorizeChannel(
|
||||
lookupChannel,
|
||||
ticket.workspaceId,
|
||||
channelId,
|
||||
ticket.sub,
|
||||
))
|
||||
) {
|
||||
socket.close(1008);
|
||||
return;
|
||||
}
|
||||
if (socket.readyState !== 1) return;
|
||||
topicPeers.get(topic)?.add(peer);
|
||||
peer.channels.add(topic);
|
||||
send(peer, { type: "subscribed", channelId });
|
||||
})()
|
||||
.catch(() => socket.close(1011))
|
||||
.finally(() => {
|
||||
peer.busy = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
setInterval(() => {
|
||||
for (const peer of peers) {
|
||||
if (peer.busy) continue;
|
||||
peer.busy = true;
|
||||
void (async () => {
|
||||
if (peer.ticket.exp <= Date.now() / 1000) {
|
||||
peer.socket.close(1008);
|
||||
return;
|
||||
}
|
||||
for (const topic of [...peer.channels]) {
|
||||
const ids = parseTopic(topic);
|
||||
if (
|
||||
!ids ||
|
||||
!(await authorizeChannel(
|
||||
lookupChannel,
|
||||
ids.workspaceId,
|
||||
ids.channelId,
|
||||
peer.ticket.sub,
|
||||
))
|
||||
) {
|
||||
revoke(peer, topic, ids?.channelId ?? "");
|
||||
peer.socket.close(1008);
|
||||
return;
|
||||
}
|
||||
}
|
||||
})()
|
||||
.catch(() => peer.socket.close(1011))
|
||||
.finally(() => {
|
||||
peer.busy = false;
|
||||
});
|
||||
}
|
||||
}, HEARTBEAT_MS).unref();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
http.once("error", reject);
|
||||
http.listen(port, "0.0.0.0", resolve);
|
||||
});
|
||||
console.log(`[chat] listening on port ${port}`);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { objects } from "@tasks/database/schema";
|
|||
import { eq } from "drizzle-orm";
|
||||
import IoRedis from "ioredis";
|
||||
import { z } from "zod";
|
||||
import { startChatServer } from "./chat.js";
|
||||
import {
|
||||
base64ToYUpdate,
|
||||
extractObjectId,
|
||||
|
|
@ -186,6 +187,8 @@ function colorFromName(name: string): string {
|
|||
async function main() {
|
||||
const port = Number(process.env.PORT) || 1234;
|
||||
|
||||
await startChatServer();
|
||||
|
||||
const extensions: Extension[] = [buildDatabaseExtension()];
|
||||
const redis = await tryCreateRedisExtension();
|
||||
if (redis) {
|
||||
|
|
|
|||
|
|
@ -1,212 +1,23 @@
|
|||
"use client";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { AlertTriangle, Bot, Loader2, Send, Sparkles, User } from "lucide-react";
|
||||
import { AIWorkspace } from "@/components/ai/ai-workspace";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function AIPage() {
|
||||
const params = useParams();
|
||||
const workspaceSlug =
|
||||
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const {
|
||||
messages,
|
||||
input,
|
||||
handleInputChange,
|
||||
handleSubmit,
|
||||
status,
|
||||
error,
|
||||
setInput,
|
||||
} = useChat({
|
||||
api: "/api/chat",
|
||||
body: { workspace: workspaceSlug },
|
||||
});
|
||||
|
||||
const isBusy = status === "submitted" || status === "streaming";
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, status]);
|
||||
export default async function AIPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspaceSlug: string }>;
|
||||
}) {
|
||||
const { workspaceSlug } = await params;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex shrink-0 items-center gap-3 border-b px-6 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Sparkles className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">AI Assistant</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ask me anything about your workspace
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full min-h-[12rem] flex-col items-center justify-center gap-4 text-muted-foreground">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
|
||||
<Bot className="size-8 text-primary" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-base font-medium">How can I help you today?</p>
|
||||
<p className="mt-1 text-sm">
|
||||
Ask me to break down a task, draft an outline, or summarize what
|
||||
you have open.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-2">
|
||||
{[
|
||||
"Break this project into subtasks",
|
||||
"Summarize my open tasks",
|
||||
"Draft a sprint plan for this week",
|
||||
].map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
onClick={() => setInput(suggestion)}
|
||||
className="rounded-full border px-3 py-1.5 text-xs transition-colors hover:bg-muted"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto max-w-3xl space-y-4 px-4 py-6">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn(
|
||||
"flex gap-3",
|
||||
msg.role === "user" && "flex-row-reverse",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
msg.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted",
|
||||
)}
|
||||
>
|
||||
{msg.role === "user" ? (
|
||||
<User className="size-4" />
|
||||
) : (
|
||||
<Bot className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[80%] rounded-xl px-4 py-2.5 text-sm whitespace-pre-wrap",
|
||||
msg.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted",
|
||||
)}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{status === "submitted" && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<Bot className="size-4" />
|
||||
</div>
|
||||
<div className="rounded-xl bg-muted px-4 py-2.5">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? <ChatErrorBanner error={error} /> : null}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t bg-background p-4">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="mx-auto flex max-w-3xl gap-2"
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (input.trim() && !isBusy) {
|
||||
// Cast: handleSubmit accepts FormEvent | KeyboardEvent; the
|
||||
// useChat helper unwraps either.
|
||||
handleSubmit(
|
||||
e as unknown as React.FormEvent<HTMLFormElement>,
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Ask anything..."
|
||||
className="flex-1 resize-none rounded-lg border bg-background px-4 py-2.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
rows={1}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={!input.trim() || isBusy || !workspaceSlug}
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatErrorBanner({ error }: { error: Error }) {
|
||||
const message = parseErrorMessage(error);
|
||||
const isUnavailable = /AI is unavailable/i.test(message);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto mb-4 mt-2 flex max-w-3xl items-start gap-3 rounded-lg border px-4 py-3 text-sm",
|
||||
isUnavailable
|
||||
? "border-amber-500/40 bg-amber-500/10 text-amber-900 dark:text-amber-200"
|
||||
: "border-destructive/40 bg-destructive/10 text-destructive",
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">
|
||||
{isUnavailable ? "AI is unavailable" : "Something went wrong"}
|
||||
<Suspense
|
||||
fallback={
|
||||
<p role="status" className="p-6">
|
||||
Loading AI workspace…
|
||||
</p>
|
||||
<p className="mt-1 break-words text-xs opacity-90">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AIWorkspace key={workspaceSlug} workspaceSlug={workspaceSlug} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `useChat` wraps fetch errors in a generic Error; the route handler returns
|
||||
* a JSON body with `{ error: string }`. Try to surface that to the user; fall
|
||||
* back to whatever message the SDK gave us.
|
||||
*/
|
||||
function parseErrorMessage(error: Error): string {
|
||||
const raw = error.message || "";
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: string };
|
||||
if (parsed?.error) return parsed.error;
|
||||
} catch {
|
||||
// not JSON; fall through
|
||||
}
|
||||
return raw || "Unknown error";
|
||||
}
|
||||
|
|
|
|||
10
apps/web/app/(app)/[workspaceSlug]/chat/[channelId]/page.tsx
Normal file
10
apps/web/app/(app)/[workspaceSlug]/chat/[channelId]/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { ChatWorkspace } from "@/components/chat/chat-workspace";
|
||||
|
||||
export default async function ChannelPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspaceSlug: string; channelId: string }>;
|
||||
}) {
|
||||
const { workspaceSlug, channelId } = await params;
|
||||
return <ChatWorkspace workspace={workspaceSlug} channelId={channelId} />;
|
||||
}
|
||||
10
apps/web/app/(app)/[workspaceSlug]/chat/page.tsx
Normal file
10
apps/web/app/(app)/[workspaceSlug]/chat/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { ChatWorkspace } from "@/components/chat/chat-workspace";
|
||||
|
||||
export default async function ChatPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspaceSlug: string }>;
|
||||
}) {
|
||||
const { workspaceSlug } = await params;
|
||||
return <ChatWorkspace workspace={workspaceSlug} />;
|
||||
}
|
||||
54
apps/web/app/api/chat/route.test.ts
Normal file
54
apps/web/app/api/chat/route.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { chatRequestSchema } from "./schema";
|
||||
|
||||
describe("chat request boundary", () => {
|
||||
const parseMessages = (messages: unknown[]) =>
|
||||
chatRequestSchema.safeParse({ workspace: "acme", messages });
|
||||
|
||||
it.each(["system", "developer", "tool"])(
|
||||
"rejects client-authored %s messages",
|
||||
(role) => {
|
||||
expect(parseMessages([{ role, content: "override instructions" }]).success).toBe(
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts user messages and assistant history from useChat", () => {
|
||||
const result = parseMessages([
|
||||
{
|
||||
id: "user-message",
|
||||
role: "user",
|
||||
content: "Help me plan this task",
|
||||
createdAt: "2026-10-23T12:00:00.000Z",
|
||||
parts: [{ type: "text", text: "Help me plan this task" }],
|
||||
},
|
||||
{
|
||||
id: "assistant-message",
|
||||
role: "assistant",
|
||||
content: "What outcome do you need?",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
workspace: "acme",
|
||||
messages: [
|
||||
{ role: "user", content: "Help me plan this task" },
|
||||
{ role: "assistant", content: "What outcome do you need?" },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ role: "user" },
|
||||
{ role: "user", content: [{ type: "text", text: "hello" }] },
|
||||
{ role: "assistant", content: 42 },
|
||||
{ role: "unknown", content: "hello" },
|
||||
])("rejects malformed message %#", (message) => {
|
||||
expect(parseMessages([message]).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,22 +5,13 @@ import { z } from "zod";
|
|||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
|
||||
import { chatRequestSchema } from "./schema";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const BASE_SYSTEM = `You are a helpful AI assistant embedded in a project management and collaboration app. Users organize work in workspaces with objects such as projects, tasks, documents, and groups. You help them plan work, clarify requirements, break down tasks, summarize content, and suggest next steps. Be concise, actionable, and friendly. Use markdown when it improves readability (bold, lists, short code snippets).`;
|
||||
|
||||
const messageSchema = z.object({
|
||||
role: z.enum(["user", "assistant", "system"]),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
const bodySchema = z.object({
|
||||
workspace: z.string().min(1),
|
||||
messages: z.array(messageSchema).min(1),
|
||||
});
|
||||
|
||||
function errorResponse(status: number, message: string): Response {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status,
|
||||
|
|
@ -34,9 +25,9 @@ export async function POST(req: Request): Promise<Response> {
|
|||
return errorResponse(401, "Sign in to use the assistant");
|
||||
}
|
||||
|
||||
let body: z.infer<typeof bodySchema>;
|
||||
let body: z.infer<typeof chatRequestSchema>;
|
||||
try {
|
||||
body = bodySchema.parse(await req.json());
|
||||
body = chatRequestSchema.parse(await req.json());
|
||||
} catch (e) {
|
||||
if (e instanceof z.ZodError) {
|
||||
return errorResponse(400, e.issues[0]?.message ?? "Invalid request");
|
||||
|
|
|
|||
28
apps/web/app/api/chat/schema.ts
Normal file
28
apps/web/app/api/chat/schema.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const clientMessageSchema = z
|
||||
.object({
|
||||
role: z.enum(["user", "assistant"]),
|
||||
content: z.string(),
|
||||
id: z.string().optional(),
|
||||
createdAt: z.coerce.date().optional(),
|
||||
parts: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.transform(({ role, content }) => ({ role, content }));
|
||||
|
||||
export const chatRequestSchema = z
|
||||
.object({
|
||||
workspace: z.string().min(1),
|
||||
messages: z.array(clientMessageSchema).min(1),
|
||||
})
|
||||
.strict();
|
||||
87
apps/web/app/api/chat/ticket/route.test.ts
Normal file
87
apps/web/app/api/chat/ticket/route.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockAuth } = vi.hoisted(() => ({
|
||||
mockAuth: vi.fn<() => Promise<{ user: { id: string } } | null>>(),
|
||||
}));
|
||||
vi.mock("@/lib/auth", () => ({ auth: mockAuth }));
|
||||
vi.mock("@/server/lib/resolve-workspace", () => ({
|
||||
resolveWorkspace: vi.fn(),
|
||||
}));
|
||||
import { auth } from "@/lib/auth";
|
||||
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
|
||||
import { POST } from "./route";
|
||||
|
||||
const publicOrigin = "https://echodo.stillwell.cloud";
|
||||
function request(
|
||||
origin: string | null,
|
||||
forwardedHost = "echodo.stillwell.cloud",
|
||||
) {
|
||||
const headers = new Headers({
|
||||
"Content-Type": "application/json",
|
||||
"x-forwarded-host": forwardedHost,
|
||||
"x-forwarded-proto": "https",
|
||||
});
|
||||
if (origin !== null) headers.set("origin", origin);
|
||||
return new Request("http://0.0.0.0:3000/api/chat/ticket", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ workspace: "example" }),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("NEXTAUTH_URL", publicOrigin);
|
||||
vi.stubEnv("AUTH_URL", "");
|
||||
vi.stubEnv("CHAT_TICKET_SECRET", "x".repeat(64));
|
||||
mockAuth.mockResolvedValue({
|
||||
user: { id: "10000000-0000-4000-8000-000000000001" },
|
||||
});
|
||||
vi.mocked(resolveWorkspace).mockResolvedValue({
|
||||
id: "20000000-0000-4000-8000-000000000001",
|
||||
} as Awaited<ReturnType<typeof resolveWorkspace>>);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Chat ticket origin boundary", () => {
|
||||
it("accepts the configured public origin behind an internal Docker URL", async () => {
|
||||
const response = await POST(request(publicOrigin));
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(resolveWorkspace).toHaveBeenCalled();
|
||||
});
|
||||
it.each([
|
||||
null,
|
||||
"null",
|
||||
"https://evil.example",
|
||||
`${publicOrigin}.evil.example`,
|
||||
`${publicOrigin}/path`,
|
||||
])("rejects origin %s before session lookup", async (origin) => {
|
||||
expect((await POST(request(origin, "evil.example"))).status).toBe(403);
|
||||
expect(auth).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not trust forged forwarded host and protocol", async () => {
|
||||
expect(
|
||||
(await POST(request("https://evil.example", "evil.example"))).status,
|
||||
).toBe(403);
|
||||
});
|
||||
it("fails closed without a configured production origin", async () => {
|
||||
vi.stubEnv("NEXTAUTH_URL", "");
|
||||
expect((await POST(request("http://0.0.0.0:3000"))).status).toBe(403);
|
||||
});
|
||||
it("fails closed with an invalid configured origin", async () => {
|
||||
vi.stubEnv("NEXTAUTH_URL", "not a URL");
|
||||
expect((await POST(request(publicOrigin))).status).toBe(403);
|
||||
});
|
||||
it("still requires an authenticated session", async () => {
|
||||
mockAuth.mockResolvedValue(null);
|
||||
expect((await POST(request(publicOrigin))).status).toBe(401);
|
||||
});
|
||||
it("does not mint a ticket when workspace authorization fails", async () => {
|
||||
vi.mocked(resolveWorkspace).mockRejectedValue(new Error("denied"));
|
||||
expect((await POST(request(publicOrigin))).status).toBe(404);
|
||||
});
|
||||
});
|
||||
77
apps/web/app/api/chat/ticket/route.ts
Normal file
77
apps/web/app/api/chat/ticket/route.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { createHmac } from "node:crypto";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
|
||||
import { z } from "zod";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
const input = z.object({ workspace: z.string().min(1).max(200) }).strict();
|
||||
const noStore = { "Cache-Control": "no-store" };
|
||||
|
||||
function trustedOrigin(request: Request): string | null {
|
||||
// Docker's request URL may be internal. Pin production to deployment configuration,
|
||||
// not Host/forwarded headers that a caller could supply outside the trusted proxy.
|
||||
const configured = process.env.AUTH_URL || process.env.NEXTAUTH_URL;
|
||||
if (!configured && process.env.NODE_ENV === "production") return null;
|
||||
const parsed = z
|
||||
.string()
|
||||
.url()
|
||||
.safeParse(configured || request.url);
|
||||
if (!parsed.success) return null;
|
||||
const url = new URL(parsed.data);
|
||||
if (
|
||||
!["https:", "http:"].includes(url.protocol) ||
|
||||
url.username ||
|
||||
url.password
|
||||
)
|
||||
return null;
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
// A browser session cookie must never be usable by a foreign origin to mint tickets.
|
||||
const origin = request.headers.get("origin");
|
||||
if (!origin || origin !== trustedOrigin(request))
|
||||
return new Response(null, { status: 403, headers: noStore });
|
||||
const session = await auth();
|
||||
if (!session?.user?.id)
|
||||
return new Response(null, { status: 401, headers: noStore });
|
||||
const secret = process.env.CHAT_TICKET_SECRET?.trim();
|
||||
if (!secret || secret.length < 32)
|
||||
return new Response(null, { status: 503, headers: noStore });
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(null, { status: 400, headers: noStore });
|
||||
}
|
||||
const parsed = input.safeParse(body);
|
||||
if (!parsed.success)
|
||||
return new Response(null, { status: 400, headers: noStore });
|
||||
try {
|
||||
const workspace = await resolveWorkspace({
|
||||
handle: parsed.data.workspace,
|
||||
userId: session.user.id,
|
||||
});
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
iss: "echodo-web",
|
||||
aud: "echodo-chat-v1",
|
||||
scope: "chat:subscribe",
|
||||
sub: session.user.id,
|
||||
workspaceId: workspace.id,
|
||||
exp: Math.floor(Date.now() / 1000) + 60,
|
||||
}),
|
||||
).toString("base64url");
|
||||
const signature = createHmac("sha256", secret)
|
||||
.update(payload)
|
||||
.digest("base64url");
|
||||
return Response.json(
|
||||
{ ticket: `${payload}.${signature}` },
|
||||
{ headers: noStore },
|
||||
);
|
||||
} catch {
|
||||
// Do not distinguish nonexistent workspaces from denied or failed membership checks.
|
||||
return new Response(null, { status: 404, headers: noStore });
|
||||
}
|
||||
}
|
||||
20
apps/web/components/ai/agent-builder.ts
Normal file
20
apps/web/components/ai/agent-builder.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export interface AgentBuilderRequest {
|
||||
workspaceSlug: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface AgentBuilderResult {
|
||||
status: "unavailable";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type AgentBuilderAdapter = (
|
||||
request: AgentBuilderRequest,
|
||||
) => Promise<AgentBuilderResult>;
|
||||
|
||||
// This boundary deliberately has no Chat transport or persistence side effects.
|
||||
export const agentBuilderStub: AgentBuilderAdapter = async () => ({
|
||||
status: "unavailable",
|
||||
message:
|
||||
"Builder is not connected yet. Your draft is kept here; no agent was created.",
|
||||
});
|
||||
103
apps/web/components/ai/agents-mode.tsx
Normal file
103
apps/web/components/ai/agents-mode.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, Bot, Loader2 } from "lucide-react";
|
||||
|
||||
import { agentBuilderStub, type AgentBuilderAdapter } from "./agent-builder";
|
||||
import { WorkspaceComposer } from "./workspace-composer";
|
||||
|
||||
export type AgentLibraryStatus =
|
||||
| { status: "loading" }
|
||||
| { status: "empty" }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
export function AgentLibraryState({ state }: { state: AgentLibraryStatus }) {
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<p role="status" className="flex items-center gap-2">
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
||||
Loading agents…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<p role="alert" className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="size-4" aria-hidden="true" />
|
||||
{state.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2 text-center">
|
||||
<Bot className="mx-auto size-8 text-primary" aria-hidden="true" />
|
||||
<h3 className="font-medium">No reusable agents yet</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Agent creation and the workspace library are coming soon. You can draft
|
||||
instructions below; nothing will be created or sent to Chat.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentsMode({
|
||||
workspaceSlug,
|
||||
active,
|
||||
builder = agentBuilderStub,
|
||||
libraryState = { status: "empty" },
|
||||
}: {
|
||||
workspaceSlug: string;
|
||||
active: boolean;
|
||||
builder?: AgentBuilderAdapter;
|
||||
libraryState?: AgentLibraryStatus;
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submitBuilderRequest() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await builder({ workspaceSlug, prompt: input.trim() });
|
||||
setNotice(result.message);
|
||||
} catch {
|
||||
setNotice(
|
||||
"Builder unavailable. Your draft has been kept; please try again later.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<section
|
||||
aria-label="Agent library"
|
||||
className="mx-auto max-w-3xl space-y-6 px-4 py-6"
|
||||
>
|
||||
<h2 className="text-lg font-semibold">Agent library</h2>
|
||||
<AgentLibraryState state={libraryState} />
|
||||
{notice && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-lg border p-3 text-sm text-muted-foreground"
|
||||
>
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<WorkspaceComposer
|
||||
mode="agents"
|
||||
active={active}
|
||||
input={input}
|
||||
onInputChange={setInput}
|
||||
busy={busy}
|
||||
onSubmit={() => {
|
||||
void submitBuilderRequest();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
apps/web/components/ai/ai-workspace.test.tsx
Normal file
155
apps/web/components/ai/ai-workspace.test.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AIWorkspace } from "./ai-workspace";
|
||||
import { AgentLibraryState } from "./agents-mode";
|
||||
import { agentBuilderStub } from "./agent-builder";
|
||||
import { aiModeHref, parseAIMode } from "./workspace-mode";
|
||||
import { WorkspaceComposer, shouldSubmitOnEnter } from "./workspace-composer";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
search: "",
|
||||
chat: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/acme/ai",
|
||||
useSearchParams: () => new URLSearchParams(mocks.search),
|
||||
}));
|
||||
vi.mock("@ai-sdk/react", () => ({ useChat: mocks.chat }));
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.search = "";
|
||||
mocks.chat.mockReset().mockReturnValue({
|
||||
messages: [],
|
||||
input: "Chat draft",
|
||||
handleSubmit: vi.fn(),
|
||||
status: "ready",
|
||||
setInput: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
describe("AI mode URLs", () => {
|
||||
it.each([null, "", "unknown", "chat"])("defaults %s to Chat", (mode) => {
|
||||
expect(parseAIMode(mode)).toBe("chat");
|
||||
});
|
||||
|
||||
it("recognizes an Agents deep link", () => {
|
||||
expect(parseAIMode("agents")).toBe("agents");
|
||||
});
|
||||
|
||||
it("preserves unrelated search parameters and the fragment", () => {
|
||||
expect(
|
||||
aiModeHref("/acme/ai", "filter=mine&tab=chat", "agents", "#library"),
|
||||
).toBe("/acme/ai?filter=mine&tab=agents#library");
|
||||
expect(aiModeHref("/acme/ai", "tab=agents", "chat")).toBe(
|
||||
"/acme/ai?tab=chat",
|
||||
);
|
||||
expect(
|
||||
aiModeHref("/acme/ai", "filter=mine&tab=agents", "chat", "#library"),
|
||||
).toBe("/acme/ai?filter=mine&tab=chat#library");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace shell", () => {
|
||||
it("keeps legacy URLs in Chat with both mode trees mounted and labelled tabs", () => {
|
||||
const html = renderToStaticMarkup(<AIWorkspace workspaceSlug="acme" />);
|
||||
expect(html).toContain('role="tablist"');
|
||||
expect(html).toMatch(/aria-selected="true"[^>]*>Chat/);
|
||||
expect(html).toMatch(/role="tabpanel"[^>]*hidden=""/);
|
||||
expect(html).toContain("Chat draft");
|
||||
expect(html).toContain("Agent library");
|
||||
expect(html).toContain('aria-label="Chat message"');
|
||||
expect(html).toContain('aria-label="Agent instructions"');
|
||||
});
|
||||
|
||||
it("renders the URL-selected mode and only one workspace-scoped Chat adapter", () => {
|
||||
mocks.search = "tab=agents";
|
||||
const html = renderToStaticMarkup(<AIWorkspace workspaceSlug="acme" />);
|
||||
expect(html).toMatch(/aria-selected="true"[^>]*>Agents/);
|
||||
expect(mocks.chat).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.chat).toHaveBeenCalledWith({
|
||||
api: "/api/chat",
|
||||
body: { workspace: "acme" },
|
||||
});
|
||||
expect(html).toContain("Builder is not connected yet");
|
||||
});
|
||||
|
||||
it("retains streaming messages, stop control, and errors", () => {
|
||||
mocks.chat.mockReturnValue({
|
||||
messages: [{ id: "m1", role: "assistant", content: "Partial answer" }],
|
||||
input: "Next question",
|
||||
handleSubmit: vi.fn(),
|
||||
setInput: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
status: "streaming",
|
||||
error: new Error("Provider unavailable"),
|
||||
});
|
||||
const html = renderToStaticMarkup(<AIWorkspace workspaceSlug="acme" />);
|
||||
expect(html).toContain("Partial answer");
|
||||
expect(html).toContain('aria-label="Stop generating"');
|
||||
expect(html).toContain("Provider unavailable");
|
||||
expect(html).toContain('role="alert"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared composer", () => {
|
||||
it.each([
|
||||
["Enter", false, false, true],
|
||||
["Enter", true, false, false],
|
||||
["Enter", false, true, false],
|
||||
["a", false, false, false],
|
||||
])(
|
||||
"handles key %s, shift %s, composing %s",
|
||||
(key, shiftKey, isComposing, expected) => {
|
||||
expect(shouldSubmitOnEnter({ key, shiftKey, isComposing })).toBe(
|
||||
expected,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("disables blank submissions and exposes the input and keyboard hint", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<WorkspaceComposer
|
||||
mode="agents"
|
||||
input=" "
|
||||
onInputChange={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('aria-label="Agent instructions"');
|
||||
expect(html).toMatch(/<button[^>]*disabled=""/);
|
||||
expect(html).toContain("Shift+Enter");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agents scaffold", () => {
|
||||
it("returns an explicit unavailable result without making a network request", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
try {
|
||||
const result = await agentBuilderStub({
|
||||
workspaceSlug: "acme",
|
||||
prompt: "Triage tasks",
|
||||
});
|
||||
expect(result.status).toBe("unavailable");
|
||||
expect(result.message).toContain("not connected");
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ status: "loading" } as const, "Loading agents"],
|
||||
[{ status: "empty" } as const, "No reusable agents yet"],
|
||||
[
|
||||
{ status: "error", message: "Library unavailable" } as const,
|
||||
"Library unavailable",
|
||||
],
|
||||
])("renders the library state %j", (state, text) => {
|
||||
expect(renderToStaticMarkup(<AgentLibraryState state={state} />)).toContain(
|
||||
text,
|
||||
);
|
||||
});
|
||||
});
|
||||
98
apps/web/components/ai/ai-workspace.tsx
Normal file
98
apps/web/components/ai/ai-workspace.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { AgentsMode } from "./agents-mode";
|
||||
import { ChatMode } from "./chat-mode";
|
||||
import { changeAIMode, parseAIMode } from "./workspace-mode";
|
||||
import { createComposerFocusMemory } from "./workspace-interaction";
|
||||
|
||||
export function AIWorkspace({ workspaceSlug }: { workspaceSlug: string }) {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = parseAIMode(searchParams.get("tab"));
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const focusMemory = useRef(createComposerFocusMemory());
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current;
|
||||
const tabHasFocus = !!root
|
||||
?.querySelector('[role="tablist"]')
|
||||
?.contains(document.activeElement);
|
||||
if (focusMemory.current.activate(mode, tabHasFocus)) {
|
||||
root
|
||||
?.querySelector<HTMLTextAreaElement>(`[data-composer-mode="${mode}"]`)
|
||||
?.focus({ preventScroll: true });
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<Tabs.Root
|
||||
key={workspaceSlug}
|
||||
value={mode}
|
||||
activationMode="manual"
|
||||
ref={rootRef}
|
||||
onFocusCapture={(event) => {
|
||||
const target = event.target;
|
||||
// Tabs retain keyboard focus; only focus within a panel changes its memory.
|
||||
if (
|
||||
!(target instanceof HTMLElement) ||
|
||||
!target.closest('[role="tabpanel"]')
|
||||
)
|
||||
return;
|
||||
focusMemory.current.record(mode, target.dataset.composerMode === mode);
|
||||
}}
|
||||
onValueChange={(value) => {
|
||||
// Next integrates native history with useSearchParams, including popstate.
|
||||
changeAIMode(window.location, parseAIMode(value), (href) => {
|
||||
window.history.pushState(null, "", href);
|
||||
});
|
||||
}}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<header className="shrink-0 border-b px-4 py-4 sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Sparkles className="size-5 text-primary" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">AI Assistant</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Chat about your workspace or draft reusable agents
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs.List aria-label="AI workspace mode" className="mt-4 flex gap-2">
|
||||
{(["chat", "agents"] as const).map((value) => (
|
||||
<Tabs.Trigger
|
||||
key={value}
|
||||
value={value}
|
||||
className="rounded-md px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[state=active]:bg-primary/10 data-[state=active]:text-primary"
|
||||
>
|
||||
{value === "chat" ? "Chat" : "Agents"}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</header>
|
||||
{/* Keeping both trees mounted retains drafts, Chat transport, and scroll containers. */}
|
||||
<Tabs.Content
|
||||
value="chat"
|
||||
forceMount
|
||||
hidden={mode !== "chat"}
|
||||
className="min-h-0 flex-1 data-[state=inactive]:hidden"
|
||||
>
|
||||
<ChatMode workspaceSlug={workspaceSlug} active={mode === "chat"} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
value="agents"
|
||||
forceMount
|
||||
hidden={mode !== "agents"}
|
||||
className="min-h-0 flex-1 data-[state=inactive]:hidden"
|
||||
>
|
||||
<AgentsMode workspaceSlug={workspaceSlug} active={mode === "agents"} />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
);
|
||||
}
|
||||
166
apps/web/components/ai/chat-mode.tsx
Normal file
166
apps/web/components/ai/chat-mode.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"use client";
|
||||
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { AlertTriangle, Bot, Loader2, User } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WorkspaceComposer } from "./workspace-composer";
|
||||
import { createChatScrollMemory } from "./workspace-interaction";
|
||||
|
||||
export function ChatMode({
|
||||
workspaceSlug,
|
||||
active,
|
||||
}: {
|
||||
workspaceSlug: string;
|
||||
active: boolean;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const scrollMemory = useRef(createChatScrollMemory());
|
||||
const { messages, input, handleSubmit, status, error, setInput, stop } =
|
||||
useChat({
|
||||
api: "/api/chat",
|
||||
body: { workspace: workspaceSlug },
|
||||
});
|
||||
const isBusy = status === "submitted" || status === "streaming";
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (container) scrollMemory.current.update(container, active);
|
||||
}, [messages, status, active]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={(event) => {
|
||||
scrollMemory.current.record(event.currentTarget, active);
|
||||
}}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full min-h-[12rem] flex-col items-center justify-center gap-4 px-4 text-muted-foreground">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
|
||||
<Bot className="size-8 text-primary" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-base font-medium">How can I help you today?</p>
|
||||
<p className="mt-1 text-sm">
|
||||
Ask me to break down a task, draft an outline, or summarize what
|
||||
you have open.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-2">
|
||||
{[
|
||||
"Break this project into subtasks",
|
||||
"Summarize my open tasks",
|
||||
"Draft a sprint plan for this week",
|
||||
].map((suggestion) => (
|
||||
<Button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
setInput(suggestion);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className="h-auto rounded-full px-3 py-1.5 text-xs"
|
||||
>
|
||||
{suggestion}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
role="log"
|
||||
aria-label="Chat messages"
|
||||
aria-live="polite"
|
||||
className="mx-auto max-w-3xl space-y-4 px-4 py-6"
|
||||
>
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn(
|
||||
"flex gap-3",
|
||||
msg.role === "user" && "flex-row-reverse",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
|
||||
msg.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted",
|
||||
)}
|
||||
>
|
||||
{msg.role === "user" ? (
|
||||
<User className="size-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<Bot className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-xl px-4 py-3 text-sm leading-relaxed",
|
||||
msg.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{msg.role === "user" ? "You" : "Assistant"}:{" "}
|
||||
</span>
|
||||
<p className="whitespace-pre-wrap break-words">
|
||||
{msg.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isBusy && messages[messages.length - 1]?.role === "user" && (
|
||||
<div role="status" className="flex gap-3">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<Bot className="size-3.5" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="rounded-xl bg-muted px-4 py-3">
|
||||
<Loader2
|
||||
className="size-4 animate-spin text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">Generating response…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div role="alert" className="mx-auto max-w-3xl px-4 pb-4">
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
<AlertTriangle
|
||||
className="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p>
|
||||
{error.message || "Something went wrong. Please try again."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<WorkspaceComposer
|
||||
mode="chat"
|
||||
active={active}
|
||||
input={input}
|
||||
onInputChange={setInput}
|
||||
onSubmit={handleSubmit}
|
||||
busy={isBusy}
|
||||
onStop={stop}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
apps/web/components/ai/composer-events.ts
Normal file
12
apps/web/components/ai/composer-events.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export function submitComposer<Event extends { preventDefault(): void }>(
|
||||
event: Event,
|
||||
input: string,
|
||||
busy: boolean,
|
||||
submit: (event: Event) => void,
|
||||
focus: () => void,
|
||||
): void {
|
||||
event.preventDefault();
|
||||
if (busy || !input.trim()) return;
|
||||
submit(event);
|
||||
focus();
|
||||
}
|
||||
121
apps/web/components/ai/workspace-composer.tsx
Normal file
121
apps/web/components/ai/workspace-composer.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import type { FormEvent, RefObject } from "react";
|
||||
import { Send, Square } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { AIMode } from "./workspace-mode";
|
||||
import { submitComposer } from "./composer-events";
|
||||
|
||||
export function shouldSubmitOnEnter(event: {
|
||||
key: string;
|
||||
shiftKey: boolean;
|
||||
isComposing: boolean;
|
||||
}): boolean {
|
||||
return event.key === "Enter" && !event.shiftKey && !event.isComposing;
|
||||
}
|
||||
|
||||
interface WorkspaceComposerProps {
|
||||
mode: AIMode;
|
||||
input: string;
|
||||
onInputChange: (value: string) => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
busy?: boolean;
|
||||
onStop?: () => void;
|
||||
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export function WorkspaceComposer({
|
||||
mode,
|
||||
input,
|
||||
onInputChange,
|
||||
onSubmit,
|
||||
busy = false,
|
||||
onStop,
|
||||
inputRef,
|
||||
active = true,
|
||||
}: WorkspaceComposerProps) {
|
||||
const localRef = useRef<HTMLTextAreaElement>(null);
|
||||
const ref = inputRef ?? localRef;
|
||||
const hintId = useId();
|
||||
const isChat = mode === "chat";
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !ref.current) return;
|
||||
ref.current.style.height = "auto";
|
||||
ref.current.style.height = `${Math.min(ref.current.scrollHeight, 200)}px`;
|
||||
}, [input, active, ref]);
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t bg-background px-4 py-4">
|
||||
<form
|
||||
aria-label={isChat ? "Chat composer" : "Agent builder composer"}
|
||||
onSubmit={(event) => {
|
||||
submitComposer(event, input, busy, onSubmit, () =>
|
||||
ref.current?.focus(),
|
||||
);
|
||||
}}
|
||||
className="mx-auto flex max-w-3xl items-end gap-3"
|
||||
>
|
||||
<textarea
|
||||
ref={ref}
|
||||
data-composer-mode={mode}
|
||||
value={input}
|
||||
onChange={(event) => onInputChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
shouldSubmitOnEnter({
|
||||
key: event.key,
|
||||
shiftKey: event.shiftKey,
|
||||
isComposing: event.nativeEvent.isComposing,
|
||||
})
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder={isChat ? "Ask anything…" : "Describe a reusable agent…"}
|
||||
aria-label={isChat ? "Chat message" : "Agent instructions"}
|
||||
aria-describedby={hintId}
|
||||
rows={1}
|
||||
readOnly={busy}
|
||||
className="max-h-[200px] min-h-[44px] flex-1 resize-none rounded-xl border bg-muted/30 px-4 py-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring read-only:opacity-60"
|
||||
/>
|
||||
{busy && onStop ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-11 w-11 shrink-0 rounded-xl"
|
||||
aria-label="Stop generating"
|
||||
onClick={() => {
|
||||
onStop();
|
||||
ref.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Square className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="h-11 w-11 shrink-0 rounded-xl"
|
||||
disabled={busy || !input.trim()}
|
||||
aria-label={isChat ? "Send message" : "Check builder availability"}
|
||||
>
|
||||
<Send className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
<p
|
||||
id={hintId}
|
||||
className="mx-auto mt-2 max-w-3xl text-center text-[10px] text-muted-foreground"
|
||||
>
|
||||
{isChat ? "AI can make mistakes. " : "Builder is not connected yet. "}
|
||||
Enter to send · Shift+Enter for a new line.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
apps/web/components/ai/workspace-interaction.test.ts
Normal file
125
apps/web/components/ai/workspace-interaction.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createComposerFocusMemory,
|
||||
createChatScrollMemory,
|
||||
} from "./workspace-interaction";
|
||||
import { changeAIMode, parseAIMode } from "./workspace-mode";
|
||||
import { submitComposer } from "./composer-events";
|
||||
import { agentBuilderStub } from "./agent-builder";
|
||||
|
||||
describe("mode navigation events", () => {
|
||||
it("pushes canonical URLs, preserves hash/query, and reads history navigation", () => {
|
||||
const history = ["https://example.test/acme/ai?filter=mine#library"];
|
||||
let index = 0;
|
||||
const navigate = (mode: "chat" | "agents") => {
|
||||
const url = new URL(history[index]);
|
||||
changeAIMode(url, mode, (href) => {
|
||||
history.splice(++index);
|
||||
history.push(new URL(href, url).href);
|
||||
});
|
||||
};
|
||||
navigate("chat");
|
||||
expect(history).toHaveLength(1);
|
||||
navigate("agents");
|
||||
expect(history[index]).toBe(
|
||||
"https://example.test/acme/ai?filter=mine&tab=agents#library",
|
||||
);
|
||||
navigate("agents");
|
||||
expect(history).toHaveLength(2);
|
||||
navigate("chat");
|
||||
expect(history[index]).toBe(
|
||||
"https://example.test/acme/ai?filter=mine&tab=chat#library",
|
||||
);
|
||||
index--;
|
||||
expect(parseAIMode(new URL(history[index]).searchParams.get("tab"))).toBe(
|
||||
"agents",
|
||||
);
|
||||
index++;
|
||||
expect(parseAIMode(new URL(history[index]).searchParams.get("tab"))).toBe(
|
||||
"chat",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composer focus memory", () => {
|
||||
it("restores the previously focused composer on history return, not initial entry", () => {
|
||||
const memory = createComposerFocusMemory();
|
||||
expect(memory.activate("chat", false)).toBe(false);
|
||||
memory.record("chat", true);
|
||||
expect(memory.activate("agents", false)).toBe(false);
|
||||
expect(memory.activate("chat", false)).toBe(true);
|
||||
});
|
||||
it("never steals keyboard tab focus and forgets focus moved elsewhere in a panel", () => {
|
||||
const memory = createComposerFocusMemory();
|
||||
memory.record("chat", true);
|
||||
expect(memory.activate("agents", true)).toBe(false);
|
||||
expect(memory.activate("chat", true)).toBe(false);
|
||||
memory.record("chat", false);
|
||||
memory.activate("agents", false);
|
||||
expect(memory.activate("chat", false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat scroll memory", () => {
|
||||
it("follows streamed content only when already near the bottom", () => {
|
||||
const memory = createChatScrollMemory();
|
||||
const container = { scrollTop: 0, scrollHeight: 1000, clientHeight: 200 };
|
||||
memory.update(container, true);
|
||||
container.scrollTop = 400;
|
||||
memory.record(container, true);
|
||||
container.scrollHeight = 1200;
|
||||
memory.update(container, true);
|
||||
expect(container.scrollTop).toBe(400);
|
||||
container.scrollTop = 980;
|
||||
memory.record(container, true);
|
||||
container.scrollHeight = 1400;
|
||||
memory.update(container, true);
|
||||
expect(container.scrollTop).toBe(1200);
|
||||
});
|
||||
it("ignores hidden scroll events and restores saved position before following new updates", () => {
|
||||
const memory = createChatScrollMemory();
|
||||
const container = { scrollTop: 0, scrollHeight: 1000, clientHeight: 200 };
|
||||
memory.update(container, true);
|
||||
container.scrollTop = 300;
|
||||
memory.record(container, true);
|
||||
memory.update(container, false);
|
||||
container.scrollTop = 0;
|
||||
memory.record(container, false);
|
||||
container.scrollHeight = 1500;
|
||||
memory.update(container, false);
|
||||
memory.update(container, true);
|
||||
expect(container.scrollTop).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composer submission events", () => {
|
||||
it("submits only through the supplied mode adapter and restores focus", async () => {
|
||||
const chat = vi.fn();
|
||||
const focus = vi.fn();
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
const agents = vi.fn(() =>
|
||||
agentBuilderStub({ workspaceSlug: "acme", prompt: "Triage tasks" }),
|
||||
);
|
||||
const event = { preventDefault: vi.fn() };
|
||||
try {
|
||||
submitComposer(event, "Triage tasks", false, agents, focus);
|
||||
expect((await agents.mock.results[0].value).status).toBe("unavailable");
|
||||
expect(chat).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(focus).toHaveBeenCalledOnce();
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
it.each([
|
||||
[" ", false],
|
||||
["Draft", true],
|
||||
])("blocks blank/busy submissions", (input, busy) => {
|
||||
const send = vi.fn();
|
||||
const focus = vi.fn();
|
||||
submitComposer({ preventDefault: vi.fn() }, input, busy, send, focus);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
50
apps/web/components/ai/workspace-interaction.ts
Normal file
50
apps/web/components/ai/workspace-interaction.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { AIMode } from "./workspace-mode";
|
||||
|
||||
export function createComposerFocusMemory() {
|
||||
const focused: Record<AIMode, boolean> = { chat: false, agents: false };
|
||||
let previous: AIMode | undefined;
|
||||
return {
|
||||
record(mode: AIMode, isComposer: boolean) {
|
||||
focused[mode] = isComposer;
|
||||
},
|
||||
activate(mode: AIMode, tabHasFocus: boolean) {
|
||||
const changed = previous !== mode;
|
||||
previous = mode;
|
||||
return changed && !tabHasFocus && focused[mode];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface ScrollContainer {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
}
|
||||
|
||||
export function createChatScrollMemory() {
|
||||
let savedTop = 0;
|
||||
let following = true;
|
||||
let wasActive = false;
|
||||
return {
|
||||
record(container: ScrollContainer, active: boolean) {
|
||||
if (!active) return;
|
||||
savedTop = container.scrollTop;
|
||||
following =
|
||||
container.scrollHeight - container.clientHeight - savedTop <= 48;
|
||||
},
|
||||
update(container: ScrollContainer, active: boolean) {
|
||||
if (active) {
|
||||
if (!wasActive) container.scrollTop = savedTop;
|
||||
else if (following)
|
||||
container.scrollTop = Math.max(
|
||||
0,
|
||||
container.scrollHeight - container.clientHeight,
|
||||
);
|
||||
savedTop = container.scrollTop;
|
||||
following =
|
||||
container.scrollHeight - container.clientHeight - savedTop <= 48;
|
||||
}
|
||||
wasActive = active;
|
||||
},
|
||||
};
|
||||
}
|
||||
27
apps/web/components/ai/workspace-mode.ts
Normal file
27
apps/web/components/ai/workspace-mode.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
export type AIMode = "chat" | "agents";
|
||||
|
||||
export function changeAIMode(
|
||||
location: { pathname: string; search: string; hash: string },
|
||||
mode: AIMode,
|
||||
push: (href: string) => void,
|
||||
): void {
|
||||
if (parseAIMode(new URLSearchParams(location.search).get("tab")) === mode)
|
||||
return;
|
||||
push(aiModeHref(location.pathname, location.search, mode, location.hash));
|
||||
}
|
||||
|
||||
export function parseAIMode(value: string | null): AIMode {
|
||||
return value === "agents" ? "agents" : "chat";
|
||||
}
|
||||
|
||||
export function aiModeHref(
|
||||
pathname: string,
|
||||
search: string,
|
||||
mode: AIMode,
|
||||
hash = "",
|
||||
): string {
|
||||
const params = new URLSearchParams(search);
|
||||
params.set("tab", mode);
|
||||
const query = params.toString();
|
||||
return `${pathname}${query ? `?${query}` : ""}${hash}`;
|
||||
}
|
||||
61
apps/web/components/chat/chat-composer.tsx
Normal file
61
apps/web/components/chat/chat-composer.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"use client";
|
||||
|
||||
import { useId, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||
import { Send } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function ChatComposer({
|
||||
onSend,
|
||||
disabled = false,
|
||||
label = "Message",
|
||||
}: {
|
||||
onSend: (body: string) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
}) {
|
||||
const [body, setBody] = useState("");
|
||||
const id = useId();
|
||||
const submit = (event?: FormEvent) => {
|
||||
event?.preventDefault();
|
||||
const text = body.trim();
|
||||
if (!text || disabled) return;
|
||||
onSend(text);
|
||||
setBody("");
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!event.shiftKey &&
|
||||
!event.nativeEvent.isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<form onSubmit={submit} className="flex items-end gap-2 border-t p-3">
|
||||
<label className="sr-only" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
<textarea
|
||||
id={id}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={disabled}
|
||||
maxLength={10000}
|
||||
rows={2}
|
||||
placeholder={`${label} (Enter to send, Shift+Enter for a new line)`}
|
||||
className="min-h-12 max-h-36 flex-1 resize-y rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disabled || !body.trim()}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Send aria-hidden="true" /> Send
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
110
apps/web/components/chat/chat-message-list.test.tsx
Normal file
110
apps/web/components/chat/chat-message-list.test.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ChatMessageList } from "./chat-message-list";
|
||||
|
||||
const message = {
|
||||
id: "server",
|
||||
sequence: "2",
|
||||
body: "Confirmed",
|
||||
authorUserId: "user",
|
||||
createdAt: "2026-01-01",
|
||||
};
|
||||
|
||||
describe("rendered message states", () => {
|
||||
it("shows a visible sending state and a retry only on failure", () => {
|
||||
const pending = {
|
||||
key: "same-key",
|
||||
body: "Hello",
|
||||
failed: false,
|
||||
retry: vi.fn(),
|
||||
};
|
||||
const sending = renderToStaticMarkup(
|
||||
<ChatMessageList messages={[]} pending={[pending]} />,
|
||||
);
|
||||
expect(sending).toContain("Sending…");
|
||||
expect(sending).not.toContain("Retry");
|
||||
const failed = renderToStaticMarkup(
|
||||
<ChatMessageList
|
||||
messages={[]}
|
||||
pending={[
|
||||
{
|
||||
...pending,
|
||||
failed: true,
|
||||
error: "Could not send message. Please retry.",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(failed).toContain('role="alert"');
|
||||
expect(failed).toContain("Retry");
|
||||
});
|
||||
|
||||
it("renders only the messages supplied by the parent", () => {
|
||||
const html = renderToStaticMarkup(<ChatMessageList messages={[message]} />);
|
||||
expect(html).toContain("Confirmed");
|
||||
expect(html).not.toContain("Sending…");
|
||||
expect(html).not.toContain("Retry");
|
||||
|
||||
const empty = renderToStaticMarkup(<ChatMessageList messages={[]} />);
|
||||
expect(empty).not.toContain("Confirmed");
|
||||
});
|
||||
|
||||
it("shows an edited indicator for edited messages", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ChatMessageList messages={[{ ...message, editedAt: "2026-01-02" }]} />,
|
||||
);
|
||||
expect(html).toContain("(edited)");
|
||||
});
|
||||
|
||||
it("only offers owner edit, owner or moderator delete, and never archived or deleted controls", () => {
|
||||
const own = renderToStaticMarkup(
|
||||
<ChatMessageList
|
||||
messages={[message]}
|
||||
currentUserId="user"
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(own).toContain("Edit message server");
|
||||
expect(own).toContain("Delete message server");
|
||||
const moderator = renderToStaticMarkup(
|
||||
<ChatMessageList
|
||||
messages={[message]}
|
||||
currentUserId="moderator"
|
||||
canModerate
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(moderator).not.toContain("Edit message server");
|
||||
expect(moderator).toContain("Delete message server");
|
||||
const archived = renderToStaticMarkup(
|
||||
<ChatMessageList
|
||||
messages={[message]}
|
||||
currentUserId="user"
|
||||
readOnly
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(archived).not.toContain("Edit message server");
|
||||
const deleted = renderToStaticMarkup(
|
||||
<ChatMessageList
|
||||
messages={[{ ...message, status: "deleted" }]}
|
||||
currentUserId="user"
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(deleted).not.toContain("Delete message server");
|
||||
});
|
||||
|
||||
it("renders deleted messages as a placeholder instead of their body", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ChatMessageList messages={[{ ...message, deletedAt: "2026-01-02" }]} />,
|
||||
);
|
||||
expect(html).toContain("Message deleted");
|
||||
expect(html).not.toContain("Confirmed");
|
||||
});
|
||||
});
|
||||
179
apps/web/components/chat/chat-message-list.tsx
Normal file
179
apps/web/components/chat/chat-message-list.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ChatMessageView = {
|
||||
id: string;
|
||||
sequence: string;
|
||||
body: string;
|
||||
authorUserId: string | null;
|
||||
createdAt: Date | string;
|
||||
editedAt?: Date | string | null;
|
||||
deletedAt?: Date | string | null;
|
||||
status?: string;
|
||||
revision?: number;
|
||||
replyCount?: number;
|
||||
parentMessageId?: string | null;
|
||||
};
|
||||
|
||||
export function ChatMessageList({
|
||||
messages,
|
||||
onViewed,
|
||||
onThread,
|
||||
onEdit,
|
||||
onDelete,
|
||||
canModerate = false,
|
||||
currentUserId,
|
||||
readOnly = false,
|
||||
pending = [],
|
||||
}: {
|
||||
messages: ChatMessageView[];
|
||||
onViewed?: (sequence: string) => void;
|
||||
onThread?: (id: string) => void;
|
||||
onEdit?: (message: ChatMessageView) => void;
|
||||
onDelete?: (message: ChatMessageView) => void;
|
||||
canModerate?: boolean;
|
||||
currentUserId?: string;
|
||||
readOnly?: boolean;
|
||||
pending?: {
|
||||
key: string;
|
||||
body: string;
|
||||
failed: boolean;
|
||||
error?: string;
|
||||
retry: () => void;
|
||||
}[];
|
||||
}) {
|
||||
const viewed = useRef(onViewed);
|
||||
useEffect(() => {
|
||||
viewed.current = onViewed;
|
||||
}, [onViewed]);
|
||||
|
||||
const list = useRef<HTMLOListElement>(null);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!viewed.current ||
|
||||
!list.current ||
|
||||
typeof IntersectionObserver === "undefined"
|
||||
)
|
||||
return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.map((entry) => (entry.target as HTMLElement).dataset.sequence)
|
||||
.filter((sequence): sequence is string => !!sequence);
|
||||
const highest = visible.reduce<string | null>(
|
||||
(max, sequence) =>
|
||||
max === null || BigInt(sequence) > BigInt(max) ? sequence : max,
|
||||
null,
|
||||
);
|
||||
if (highest) viewed.current?.(highest);
|
||||
},
|
||||
{ threshold: 0.5 },
|
||||
);
|
||||
list.current
|
||||
.querySelectorAll("[data-sequence]")
|
||||
.forEach((element) => observer.observe(element));
|
||||
return () => observer.disconnect();
|
||||
}, [messages, pending]);
|
||||
|
||||
return (
|
||||
<ol ref={list} aria-label="Messages" className="space-y-3 p-4">
|
||||
{messages.map((message) => (
|
||||
<li
|
||||
key={message.id}
|
||||
data-sequence={message.sequence}
|
||||
className="rounded-md border p-3 text-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">
|
||||
{message.authorUserId}
|
||||
</span>
|
||||
<time dateTime={new Date(message.createdAt).toISOString()}>
|
||||
{new Date(message.createdAt).toLocaleString()}
|
||||
</time>
|
||||
{message.editedAt && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
(edited)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 whitespace-pre-wrap break-words">
|
||||
{message.deletedAt || message.status === "deleted"
|
||||
? "Message deleted"
|
||||
: message.body}
|
||||
</p>
|
||||
{!readOnly && !message.deletedAt && message.status !== "deleted" && (
|
||||
<div className="flex gap-2">
|
||||
{onEdit && message.authorUserId === currentUserId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(message)}
|
||||
aria-label={`Edit message ${message.id}`}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{onDelete &&
|
||||
(message.authorUserId === currentUserId || canModerate) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(message)}
|
||||
aria-label={`Delete message ${message.id}`}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{onThread && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onThread(message.id)}
|
||||
aria-label={`Open replies to message by ${message.authorUserId}`}
|
||||
>
|
||||
{message.replyCount ? `${message.replyCount} replies` : "Reply"}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{pending.map((message) => (
|
||||
<li
|
||||
key={message.key}
|
||||
className={cn(
|
||||
"rounded-md border p-3 text-sm",
|
||||
message.failed && "border-destructive",
|
||||
)}
|
||||
>
|
||||
<p className="whitespace-pre-wrap break-words">{message.body}</p>
|
||||
<p
|
||||
role={message.failed ? "alert" : "status"}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{message.failed ? message.error : "Sending…"}
|
||||
</p>
|
||||
{message.failed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={message.retry}
|
||||
>
|
||||
Retry send
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
79
apps/web/components/chat/chat-scope.test.ts
Normal file
79
apps/web/components/chat/chat-scope.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createChatScope } from "./chat-utils";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("chat async scope", () => {
|
||||
it("ignores a send resolved after a channel switch", async () => {
|
||||
const scope = createChatScope("acme", "first");
|
||||
const request = deferred<string>();
|
||||
const result: string[] = [];
|
||||
const valid = scope.capture();
|
||||
const completion = request.promise.then((message) => {
|
||||
if (valid()) result.push(message);
|
||||
});
|
||||
scope.select("acme", "second");
|
||||
request.resolve("first-channel message");
|
||||
await completion;
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a late failed send, including a permission error", async () => {
|
||||
const scope = createChatScope("acme", "first");
|
||||
const request = deferred<string>();
|
||||
const result: string[] = [];
|
||||
const valid = scope.capture();
|
||||
const completion = request.promise.catch((error: Error) => {
|
||||
if (valid()) result.push(error.message);
|
||||
});
|
||||
scope.select("acme", "second");
|
||||
request.reject(new Error("FORBIDDEN"));
|
||||
await completion;
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a stale mutation result and its subsequent refresh", async () => {
|
||||
const scope = createChatScope("acme", "first");
|
||||
const mutation = deferred<string>();
|
||||
const refresh = deferred<string>();
|
||||
const result: string[] = [];
|
||||
const valid = scope.capture();
|
||||
const completion = mutation.promise.then(async (value) => {
|
||||
if (!valid()) return;
|
||||
result.push(value);
|
||||
const page = await refresh.promise;
|
||||
if (valid()) result.push(page);
|
||||
});
|
||||
mutation.resolve("edited");
|
||||
await Promise.resolve();
|
||||
scope.select("acme", "second");
|
||||
refresh.resolve("old page");
|
||||
await completion;
|
||||
expect(result).toEqual(["edited"]);
|
||||
});
|
||||
|
||||
it("invalidates pending work on purge before redirect and cannot remove a new channel's cache", async () => {
|
||||
const scope = createChatScope("acme", "first");
|
||||
const cancellation = deferred<void>();
|
||||
const result: string[] = [];
|
||||
const valid = scope.capture();
|
||||
scope.revoke();
|
||||
result.push("redirect");
|
||||
const cleanup = cancellation.promise.then(() => {
|
||||
if (valid()) result.push("remove queries");
|
||||
});
|
||||
scope.select("acme", "second");
|
||||
cancellation.resolve();
|
||||
await cleanup;
|
||||
expect(result).toEqual(["redirect"]);
|
||||
expect(scope.capture()()).toBe(true);
|
||||
});
|
||||
});
|
||||
101
apps/web/components/chat/chat-utils.test.ts
Normal file
101
apps/web/components/chat/chat-utils.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
advanceCatchUpWindow,
|
||||
chatBadge,
|
||||
mergeChatMessages,
|
||||
messageError,
|
||||
rootReadIsProven,
|
||||
} from "./chat-utils";
|
||||
|
||||
describe("chat client state", () => {
|
||||
it("retains through across pages, then starts a new interval after exhaustion", () => {
|
||||
const first = advanceCatchUpWindow(
|
||||
{ after: "5" },
|
||||
{ next: "8", through: "10", hasMore: true },
|
||||
);
|
||||
expect(first).toEqual({ after: "8", through: "10" });
|
||||
const second = advanceCatchUpWindow(first, {
|
||||
next: "9",
|
||||
through: "10",
|
||||
hasMore: true,
|
||||
});
|
||||
expect(second).toEqual({ after: "9", through: "10" });
|
||||
expect(
|
||||
advanceCatchUpWindow(second, {
|
||||
next: "9",
|
||||
through: "10",
|
||||
hasMore: false,
|
||||
}),
|
||||
).toEqual({ after: "10" });
|
||||
});
|
||||
|
||||
it("does not lose the cursor on a stalled page or fold concurrent sends into the old interval", () => {
|
||||
const window = { after: "8", through: "10" };
|
||||
expect(
|
||||
advanceCatchUpWindow(window, { next: "8", through: "10", hasMore: true }),
|
||||
).toBe(window);
|
||||
expect(
|
||||
advanceCatchUpWindow(window, {
|
||||
next: "10",
|
||||
through: "10",
|
||||
hasMore: false,
|
||||
}),
|
||||
).toEqual({ after: "10" });
|
||||
expect(
|
||||
advanceCatchUpWindow(
|
||||
{ after: "10" },
|
||||
{ next: "11", through: "12", hasMore: true },
|
||||
),
|
||||
).toEqual({ after: "11", through: "12" });
|
||||
});
|
||||
it("never replaces a newer edit or tombstone with an older page", () => {
|
||||
const original = {
|
||||
id: "m",
|
||||
sequence: "1",
|
||||
revision: 1,
|
||||
body: "old",
|
||||
status: "active",
|
||||
};
|
||||
const edited = { ...original, revision: 2, body: "new" };
|
||||
const deleted = { ...edited, revision: 3, body: "", status: "deleted" };
|
||||
expect(mergeChatMessages([edited], [original])).toEqual([edited]);
|
||||
expect(mergeChatMessages([deleted], [edited])).toEqual([deleted]);
|
||||
});
|
||||
it("requires a channel-wide scan and does not claim unseen replies as read", () => {
|
||||
expect(rootReadIsProven("20", null, null, "0")).toBe(false);
|
||||
expect(rootReadIsProven("20", "19", null, "0")).toBe(false);
|
||||
expect(rootReadIsProven("20", "25", "18", "0")).toBe(false);
|
||||
expect(rootReadIsProven("20", "25", "22", "0")).toBe(true);
|
||||
});
|
||||
|
||||
it("deduplicates history and catch-up in sequence order", () => {
|
||||
expect(
|
||||
mergeChatMessages(
|
||||
[
|
||||
{ id: "b", sequence: "9" },
|
||||
{ id: "a", sequence: "2" },
|
||||
],
|
||||
[
|
||||
{ id: "b", sequence: "9" },
|
||||
{ id: "c", sequence: "10" },
|
||||
],
|
||||
).map((m) => m.id),
|
||||
).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("distinguishes unread dots from numeric direct and mention activity", () => {
|
||||
expect(
|
||||
chatBadge({ unreadCount: 3, mentionCount: 0, directUnreadCount: 0 }),
|
||||
).toEqual({ dot: true, count: 0 });
|
||||
expect(
|
||||
chatBadge({ unreadCount: 3, mentionCount: 2, directUnreadCount: 1 }),
|
||||
).toEqual({ dot: true, count: 3 });
|
||||
});
|
||||
|
||||
it("maps failed sends to actionable messages", () => {
|
||||
expect(messageError("NOT_FOUND")).toMatch(/access/i);
|
||||
expect(messageError("CONFLICT")).toMatch(/different message/i);
|
||||
expect(messageError("INTERNAL_SERVER_ERROR")).toMatch(/retry/i);
|
||||
});
|
||||
});
|
||||
103
apps/web/components/chat/chat-utils.ts
Normal file
103
apps/web/components/chat/chat-utils.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
export function mergeChatMessages<
|
||||
T extends { id: string; sequence: string; revision?: number },
|
||||
>(current: T[], incoming: T[]): T[] {
|
||||
const byId = new Map(current.map((message) => [message.id, message]));
|
||||
for (const message of incoming) {
|
||||
const previous = byId.get(message.id);
|
||||
if (!previous || (message.revision ?? 1) >= (previous.revision ?? 1))
|
||||
byId.set(message.id, message);
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => {
|
||||
const left = BigInt(a.sequence);
|
||||
const right = BigInt(b.sequence);
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Each async continuation retains the generation it started in. A return to the
|
||||
// same channel is still a new generation, so old completions cannot re-enter it.
|
||||
export function createChatScope(workspace: string, channelId?: string) {
|
||||
let key = JSON.stringify([workspace, channelId ?? ""]);
|
||||
let generation = 0;
|
||||
let revoked = false;
|
||||
return {
|
||||
select(nextWorkspace: string, nextChannelId?: string) {
|
||||
const next = JSON.stringify([nextWorkspace, nextChannelId ?? ""]);
|
||||
if (next === key) return;
|
||||
key = next;
|
||||
generation++;
|
||||
revoked = false;
|
||||
},
|
||||
revoke() {
|
||||
if (!revoked) {
|
||||
revoked = true;
|
||||
generation++;
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
revoked = true;
|
||||
generation++;
|
||||
},
|
||||
capture() {
|
||||
const captured = generation;
|
||||
return () => !revoked && captured === generation;
|
||||
},
|
||||
captureCleanup() {
|
||||
const captured = generation;
|
||||
return () => revoked && captured === generation;
|
||||
},
|
||||
isRevoked() {
|
||||
return revoked;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type CatchUpWindow = { after: string; through?: string };
|
||||
|
||||
// Keep the first response's high-water fixed until every page of that interval is read.
|
||||
export function advanceCatchUpWindow(
|
||||
window: CatchUpWindow,
|
||||
page: { next: string; through: string; hasMore: boolean },
|
||||
): CatchUpWindow {
|
||||
if (page.hasMore) {
|
||||
if (BigInt(page.next) <= BigInt(window.after)) return window;
|
||||
return { after: page.next, through: window.through ?? page.through };
|
||||
}
|
||||
return { after: page.through };
|
||||
}
|
||||
|
||||
export function rootReadIsProven(
|
||||
sequence: string,
|
||||
scanned: string | null,
|
||||
unseenReply: string | null,
|
||||
readBoundary: string,
|
||||
): boolean {
|
||||
return (
|
||||
scanned !== null &&
|
||||
BigInt(scanned) >= BigInt(sequence) &&
|
||||
(unseenReply === null ||
|
||||
BigInt(unseenReply) > BigInt(sequence) ||
|
||||
BigInt(unseenReply) <= BigInt(readBoundary))
|
||||
);
|
||||
}
|
||||
|
||||
export function chatBadge(summary: {
|
||||
unreadCount: number;
|
||||
mentionCount: number;
|
||||
directUnreadCount: number;
|
||||
}) {
|
||||
return {
|
||||
dot: summary.unreadCount > 0,
|
||||
count: summary.mentionCount + summary.directUnreadCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function messageError(code?: string): string {
|
||||
if (code === "NOT_FOUND" || code === "FORBIDDEN")
|
||||
return "You no longer have access to this channel.";
|
||||
if (code === "CONFLICT")
|
||||
return "This request was already used for a different message. Please compose a new message.";
|
||||
if (code === "BAD_REQUEST")
|
||||
return "This message could not be sent. Check the text and retry.";
|
||||
return "Could not send message. Please retry.";
|
||||
}
|
||||
229
apps/web/components/chat/chat-workspace.test.tsx
Normal file
229
apps/web/components/chat/chat-workspace.test.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ChatWorkspace, ReadFailureAlert } from "./chat-workspace";
|
||||
|
||||
const mock = vi.hoisted(() => ({
|
||||
channel: {
|
||||
id: "channel",
|
||||
kind: "public",
|
||||
name: "General",
|
||||
archivedAt: null as string | null,
|
||||
},
|
||||
channels: {
|
||||
data: [] as unknown[],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null as unknown,
|
||||
refetch: vi.fn(),
|
||||
},
|
||||
resolved: {
|
||||
data: { id: "workspace" },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
},
|
||||
history: {
|
||||
data: { messages: [] as unknown[], hasMore: false },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null as unknown,
|
||||
refetch: vi.fn(),
|
||||
},
|
||||
thread: { data: null, isError: false, error: null, refetch: vi.fn() },
|
||||
catchUp: { data: null, isError: false, error: null, refetch: vi.fn() },
|
||||
summary: { data: { channels: [] as unknown[] }, error: null as unknown },
|
||||
catchUpQuery: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
recovery: { messages: [] as unknown[], error: null as unknown, refresh: vi.fn() },
|
||||
}));
|
||||
vi.mock("./use-chat-mutation-recovery", () => ({ useChatMutationRecovery: () => mock.recovery }));
|
||||
vi.mock("next-auth/react", () => ({
|
||||
useSession: () => ({ data: { user: { id: "user" } } }),
|
||||
}));
|
||||
vi.mock("@/lib/use-chat-subscription", () => ({
|
||||
useChatSubscription: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: mock.replace }),
|
||||
}));
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ cancelQueries: vi.fn(), removeQueries: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/trpc", () => ({
|
||||
api: {
|
||||
useUtils: () => ({ chat: {} }),
|
||||
workspaces: { resolve: { useQuery: () => mock.resolved } },
|
||||
chat: {
|
||||
viewer: { useQuery: () => ({ data: { role: "member" } }) },
|
||||
editMessage: {
|
||||
useMutation: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
},
|
||||
deleteMessage: {
|
||||
useMutation: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
},
|
||||
listChannels: { useQuery: () => mock.channels },
|
||||
unreadSummary: { useQuery: () => mock.summary },
|
||||
history: { useQuery: () => mock.history },
|
||||
threadHistory: { useQuery: () => mock.thread },
|
||||
catchUp: {
|
||||
useQuery: (...args: unknown[]) => {
|
||||
mock.catchUpQuery(...args);
|
||||
return mock.catchUp;
|
||||
},
|
||||
},
|
||||
send: { useMutation: () => ({ mutateAsync: vi.fn() }) },
|
||||
markChannelRead: { useMutation: () => ({ mutate: vi.fn() }) },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mock.recovery.messages = [];
|
||||
mock.recovery.error = null;
|
||||
mock.channel.archivedAt = null;
|
||||
mock.channels.data = [mock.channel];
|
||||
mock.channels.isLoading = false;
|
||||
mock.channels.isError = false;
|
||||
mock.resolved.isLoading = false;
|
||||
mock.resolved.isError = false;
|
||||
mock.history.data = { messages: [], hasMore: false };
|
||||
mock.history.isLoading = false;
|
||||
mock.history.isError = false;
|
||||
mock.history.error = null;
|
||||
mock.summary.data = { channels: [] };
|
||||
mock.catchUp.error = null;
|
||||
mock.catchUp.isError = false;
|
||||
mock.catchUpQuery.mockClear();
|
||||
});
|
||||
|
||||
const render = () =>
|
||||
renderToStaticMarkup(<ChatWorkspace workspace="acme" channelId="channel" />);
|
||||
|
||||
describe("rendered chat workspace", () => {
|
||||
it("exposes a retryable read failure without claiming it advanced", () => {
|
||||
const html = renderToStaticMarkup(<ReadFailureAlert onRetry={vi.fn()} />);
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain("Unread status has not advanced");
|
||||
expect(html).toContain("Retry");
|
||||
});
|
||||
it("renders an ordinary unread dot and numeric mentions/direct activity", () => {
|
||||
mock.channels.data = [
|
||||
mock.channel,
|
||||
{ id: "dm", kind: "direct", name: null, archivedAt: null },
|
||||
];
|
||||
mock.summary.data = {
|
||||
channels: [
|
||||
{ channelId: "channel", unreadCount: 5, mentionCount: 0 },
|
||||
{ channelId: "dm", unreadCount: 3, mentionCount: 0 },
|
||||
],
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('aria-label="Unread messages"');
|
||||
expect(html).toContain('aria-label="3 mentions or direct unread messages"');
|
||||
expect(html).not.toContain(">5</span>");
|
||||
});
|
||||
|
||||
it("separates loading, transient failure and unavailable channel", () => {
|
||||
mock.channels.isLoading = true;
|
||||
expect(render()).toContain("Loading channel");
|
||||
mock.channels.isLoading = false;
|
||||
mock.channels.isError = true;
|
||||
expect(render()).toContain("Could not load channel");
|
||||
expect(render()).not.toContain("Channel unavailable");
|
||||
mock.channels.isError = false;
|
||||
mock.channels.data = [];
|
||||
expect(render()).toContain("Channel unavailable");
|
||||
});
|
||||
|
||||
it("shows history loading rather than a misleading empty state", () => {
|
||||
mock.history.isLoading = true;
|
||||
expect(render()).toContain("Loading messages");
|
||||
expect(render()).not.toContain("No messages yet");
|
||||
});
|
||||
|
||||
it("overlays a recovered tombstone on stale history without adding unloaded rows", () => {
|
||||
const original = { id: "m", sequence: "1", revision: 1, body: "secret original", authorUserId: "user", createdAt: "2026-01-01", status: "active" };
|
||||
mock.history.data.messages = [original];
|
||||
mock.recovery.messages = [{ ...original, revision: 3, body: "", status: "deleted", deletedAt: "2026-01-02" }, { ...original, id: "unloaded", sequence: "2", body: "unloaded edit" }];
|
||||
const html = render();
|
||||
expect(html).not.toContain("secret original");
|
||||
expect(html).not.toContain("unloaded edit");
|
||||
expect(html).toContain("deleted");
|
||||
});
|
||||
|
||||
it("hides history when mutation recovery discovers revoked access", () => {
|
||||
mock.recovery.error = { data: { code: "NOT_FOUND" } };
|
||||
expect(render()).toContain("Channel unavailable or access removed");
|
||||
});
|
||||
|
||||
it("hides revoked content rather than showing a transient retry", () => {
|
||||
mock.history.error = { data: { code: "NOT_FOUND" } };
|
||||
mock.history.isError = true;
|
||||
const html = render();
|
||||
expect(html).toContain("Channel unavailable or access removed");
|
||||
expect(html).not.toContain("Could not load messages");
|
||||
});
|
||||
|
||||
it("shows transient history failure with retry", () => {
|
||||
mock.history.isError = true;
|
||||
const html = render();
|
||||
expect(html).toContain("Could not load messages");
|
||||
expect(html).toContain("Retry");
|
||||
});
|
||||
|
||||
it("keeps archived channels readable without a channel composer", () => {
|
||||
mock.channel.archivedAt = "2026-01-01";
|
||||
const html = render();
|
||||
expect(html).toContain('aria-label="Conversation: General"');
|
||||
expect(html).not.toContain('aria-label="Send message"');
|
||||
});
|
||||
|
||||
it("bounds initial catch-up to the oldest root predecessor when complete root history has no replies", () => {
|
||||
mock.history.data = {
|
||||
messages: [
|
||||
{
|
||||
id: "a",
|
||||
sequence: "12",
|
||||
replyCount: 0,
|
||||
body: "First",
|
||||
authorUserId: "user",
|
||||
createdAt: "2026-01-01",
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
sequence: "30",
|
||||
replyCount: 0,
|
||||
body: "Second",
|
||||
authorUserId: "user",
|
||||
createdAt: "2026-01-01",
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
};
|
||||
render();
|
||||
expect(mock.catchUpQuery.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
after: "11",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not skip replies before the initial root page", () => {
|
||||
mock.history.data = {
|
||||
messages: [
|
||||
{
|
||||
id: "a",
|
||||
sequence: "30",
|
||||
replyCount: 1,
|
||||
body: "root",
|
||||
authorUserId: "user",
|
||||
createdAt: "2026-01-01",
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
};
|
||||
render();
|
||||
expect(mock.catchUpQuery.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
after: "0",
|
||||
});
|
||||
});
|
||||
});
|
||||
1075
apps/web/components/chat/chat-workspace.tsx
Normal file
1075
apps/web/components/chat/chat-workspace.tsx
Normal file
File diff suppressed because it is too large
Load diff
47
apps/web/components/chat/mutation-recovery.test.ts
Normal file
47
apps/web/components/chat/mutation-recovery.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMutationRecovery } from "./mutation-recovery";
|
||||
|
||||
const page = (next: string, through: string, hasMore = false) => ({
|
||||
messages: [{ id: "m", sequence: "1", revision: Number(next) }], next, through, hasMore,
|
||||
});
|
||||
|
||||
describe("mutation recovery", () => {
|
||||
it("drains a fixed high water, then resumes independently of creation sequences", async () => {
|
||||
const fetch = vi.fn().mockResolvedValueOnce(page("2", "4", true)).mockResolvedValueOnce(page("4", "4")).mockResolvedValueOnce(page("5", "5"));
|
||||
const apply = vi.fn();
|
||||
const recovery = createMutationRecovery(fetch, apply);
|
||||
await recovery.run();
|
||||
await recovery.run();
|
||||
expect(fetch.mock.calls.map(([cursor]) => cursor)).toEqual([{ after: "0" }, { after: "2", through: "4" }, { after: "4" }]);
|
||||
expect(apply).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
it("retries the same cursor after projection application fails", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(page("1", "1"));
|
||||
const apply = vi.fn().mockImplementationOnce(() => { throw new Error("merge failed"); });
|
||||
const recovery = createMutationRecovery(fetch, apply);
|
||||
await expect(recovery.run()).rejects.toThrow("merge failed");
|
||||
await recovery.run();
|
||||
expect(fetch.mock.calls.map(([cursor]) => cursor)).toEqual([{ after: "0" }, { after: "0" }]);
|
||||
});
|
||||
it("ignores a response after scope disposal", async () => {
|
||||
let resolve!: (value: ReturnType<typeof page>) => void;
|
||||
const fetch = vi.fn(() => new Promise<ReturnType<typeof page>>((done) => { resolve = done; }));
|
||||
const apply = vi.fn();
|
||||
const recovery = createMutationRecovery(fetch, apply);
|
||||
const pending = recovery.run();
|
||||
recovery.stop();
|
||||
resolve(page("1", "1"));
|
||||
await pending;
|
||||
expect(apply).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects stalled pages instead of spinning", async () => {
|
||||
const recovery = createMutationRecovery(async () => page("0", "2", true), vi.fn());
|
||||
await expect(recovery.run()).rejects.toThrow();
|
||||
});
|
||||
it("advances empty completed windows without looping", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({ ...page("0", "0"), messages: [] });
|
||||
const recovery = createMutationRecovery(fetch, vi.fn());
|
||||
await recovery.run();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
45
apps/web/components/chat/mutation-recovery.ts
Normal file
45
apps/web/components/chat/mutation-recovery.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { CatchUpWindow } from "./chat-utils";
|
||||
|
||||
type Page<T> = { messages: T[]; next: string; through: string; hasMore: boolean };
|
||||
|
||||
export function createMutationRecovery<T>(
|
||||
fetchPage: (cursor: CatchUpWindow) => Promise<Page<T>>,
|
||||
apply: (messages: T[]) => void,
|
||||
initialCursor: CatchUpWindow = { after: "0" },
|
||||
onCursor?: (cursor: CatchUpWindow) => void,
|
||||
) {
|
||||
let cursor: CatchUpWindow = { ...initialCursor };
|
||||
let stopped = false;
|
||||
let active: Promise<void> | undefined;
|
||||
let requested = false;
|
||||
async function drain() {
|
||||
do {
|
||||
requested = false;
|
||||
let more: boolean;
|
||||
do {
|
||||
const page = await fetchPage({ ...cursor });
|
||||
if (stopped) return;
|
||||
if (
|
||||
BigInt(page.through) < BigInt(cursor.after) ||
|
||||
BigInt(page.next) < BigInt(cursor.after) ||
|
||||
BigInt(page.next) > BigInt(page.through) ||
|
||||
(cursor.through !== undefined && cursor.through !== page.through) ||
|
||||
(page.hasMore && BigInt(page.next) <= BigInt(cursor.after))
|
||||
) throw new Error("Invalid mutation recovery window");
|
||||
apply(page.messages);
|
||||
cursor = page.hasMore ? { after: page.next, through: page.through } : { after: page.through };
|
||||
onCursor?.({ ...cursor });
|
||||
more = page.hasMore;
|
||||
} while (more && !stopped);
|
||||
} while (requested && !stopped);
|
||||
}
|
||||
return {
|
||||
run(): Promise<void> {
|
||||
if (stopped) return Promise.resolve();
|
||||
if (active) { requested = true; return active; }
|
||||
active = drain().finally(() => { active = undefined; });
|
||||
return active;
|
||||
},
|
||||
stop() { stopped = true; },
|
||||
};
|
||||
}
|
||||
64
apps/web/components/chat/use-chat-mutation-recovery.tsx
Normal file
64
apps/web/components/chat/use-chat-mutation-recovery.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/trpc";
|
||||
import type { ChatMessageView } from "./chat-message-list";
|
||||
import { mergeChatMessages } from "./chat-utils";
|
||||
import { createMutationRecovery } from "./mutation-recovery";
|
||||
|
||||
export function useChatMutationRecovery({ workspace, channelId, enabled }: {
|
||||
workspace: string;
|
||||
channelId?: string;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const identity = JSON.stringify([workspace, channelId, enabled]);
|
||||
const scope = useRef({ identity });
|
||||
if (scope.current.identity !== identity) scope.current = { identity };
|
||||
const token = scope.current;
|
||||
const [state, setState] = useState<{ token: typeof token; messages: ChatMessageView[]; error: unknown }>({ token, messages: [], error: null });
|
||||
const cursors = useRef(new Map<string, { after: string; through?: string }>());
|
||||
const run = useRef<(() => void) | undefined>(undefined);
|
||||
const cursorKey = JSON.stringify([workspace, channelId ?? ""]);
|
||||
const refresh = useCallback(() => { run.current?.(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !channelId) return;
|
||||
let stopped = false;
|
||||
const valid = () => !stopped && scope.current === token;
|
||||
const cursorKey = JSON.stringify([workspace, channelId]);
|
||||
const recovery = createMutationRecovery(
|
||||
(cursor) => utils.chat.mutationCatchUp.fetch({ workspace, channelId, ...cursor, limit: 100 }),
|
||||
(messages) => {
|
||||
if (!valid()) return;
|
||||
setState((previous) => ({ token, messages: mergeChatMessages(previous.token === token ? previous.messages : [], messages), error: null }));
|
||||
},
|
||||
cursors.current.get(cursorKey),
|
||||
(cursor) => cursors.current.set(cursorKey, cursor),
|
||||
);
|
||||
const recover = () => {
|
||||
void recovery.run().catch((error: unknown) => {
|
||||
if (valid()) setState((previous) => ({ token, messages: previous.token === token ? previous.messages : [], error }));
|
||||
});
|
||||
};
|
||||
run.current = recover;
|
||||
recover();
|
||||
const timer = setInterval(recover, 15000);
|
||||
window.addEventListener("online", recover);
|
||||
window.addEventListener("focus", recover);
|
||||
return () => {
|
||||
stopped = true;
|
||||
recovery.stop();
|
||||
clearInterval(timer);
|
||||
window.removeEventListener("online", recover);
|
||||
window.removeEventListener("focus", recover);
|
||||
if (run.current === recover) run.current = undefined;
|
||||
};
|
||||
}, [workspace, channelId, enabled, token, utils]);
|
||||
|
||||
return {
|
||||
messages: state.token === token && enabled ? state.messages : [],
|
||||
error: state.token === token && enabled ? state.error : null,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import type { ComponentType } from "react";
|
||||
import {
|
||||
CalendarDays,
|
||||
MessageCircle,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
Home,
|
||||
|
|
@ -16,9 +17,15 @@ import {
|
|||
} from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { chatBadge } from "@/components/chat/chat-utils";
|
||||
|
||||
export interface IconRailProps {
|
||||
className?: string;
|
||||
|
|
@ -35,11 +42,22 @@ const NAV_ITEMS: NavItem[] = [
|
|||
{ id: "home", label: "Home", segment: "", icon: Home },
|
||||
{ id: "spaces", label: "Spaces", segment: "", icon: LayoutGrid },
|
||||
{ id: "planner", label: "Planner", segment: "planner", icon: CalendarDays },
|
||||
{ id: "mission-control", label: "Mission control", segment: "mission-control", icon: Radar },
|
||||
{
|
||||
id: "mission-control",
|
||||
label: "Mission control",
|
||||
segment: "mission-control",
|
||||
icon: Radar,
|
||||
},
|
||||
{ id: "ai", label: "AI", segment: "ai", icon: Sparkles },
|
||||
{ id: "chat", label: "Chat", segment: "chat", icon: MessageCircle },
|
||||
{ id: "teams", label: "Teams", segment: "teams", icon: Users },
|
||||
{ id: "docs", label: "Docs", segment: "docs", icon: FileText },
|
||||
{ id: "whiteboards", label: "Whiteboards", segment: "whiteboards", icon: Presentation },
|
||||
{
|
||||
id: "whiteboards",
|
||||
label: "Whiteboards",
|
||||
segment: "whiteboards",
|
||||
icon: Presentation,
|
||||
},
|
||||
{ id: "forms", label: "Forms", segment: "forms", icon: ClipboardList },
|
||||
];
|
||||
|
||||
|
|
@ -51,6 +69,7 @@ const RESERVED_ROOT_SEGMENTS = new Set([
|
|||
"planner",
|
||||
"mission-control",
|
||||
"ai",
|
||||
"chat",
|
||||
"teams",
|
||||
"docs",
|
||||
"whiteboards",
|
||||
|
|
@ -74,7 +93,8 @@ function isNavItemActive(
|
|||
if (!pathname.startsWith(prefix)) return false;
|
||||
|
||||
if (item.segment) {
|
||||
const next = pathname[prefix.length] === "/" ? pathname.slice(prefix.length + 1) : "";
|
||||
const next =
|
||||
pathname[prefix.length] === "/" ? pathname.slice(prefix.length + 1) : "";
|
||||
const first = next.split("/")[0] ?? "";
|
||||
return first === item.segment;
|
||||
}
|
||||
|
|
@ -99,11 +119,39 @@ export function IconRail({ className }: IconRailProps) {
|
|||
const slugParam =
|
||||
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
|
||||
const workspaceSegment =
|
||||
currentWorkspace?.slug ||
|
||||
currentWorkspace?.id ||
|
||||
slugParam ||
|
||||
undefined;
|
||||
currentWorkspace?.slug || currentWorkspace?.id || slugParam || undefined;
|
||||
const workspaceName = currentWorkspace?.name ?? "";
|
||||
const unread = api.chat.unreadSummary.useQuery(
|
||||
{ workspace: workspaceSegment ?? "" },
|
||||
{ enabled: !!workspaceSegment, refetchInterval: 15000 },
|
||||
);
|
||||
const visibleChannels = api.chat.listChannels.useQuery(
|
||||
{ workspace: workspaceSegment ?? "" },
|
||||
{ enabled: !!workspaceSegment, refetchInterval: 15000 },
|
||||
);
|
||||
const directIds = new Set(
|
||||
visibleChannels.data
|
||||
?.filter((channel) => channel.kind === "direct")
|
||||
.map((channel) => channel.id),
|
||||
);
|
||||
const badge = chatBadge({
|
||||
unreadCount:
|
||||
unread.data?.channels.reduce(
|
||||
(total, channel) => total + channel.unreadCount,
|
||||
0,
|
||||
) ?? 0,
|
||||
mentionCount:
|
||||
unread.data?.channels.reduce(
|
||||
(total, channel) => total + channel.mentionCount,
|
||||
0,
|
||||
) ?? 0,
|
||||
directUnreadCount:
|
||||
unread.data?.channels.reduce(
|
||||
(total, channel) =>
|
||||
total + (directIds.has(channel.channelId) ? channel.unreadCount : 0),
|
||||
0,
|
||||
) ?? 0,
|
||||
});
|
||||
|
||||
const initials = workspaceName.trim().slice(0, 2).toUpperCase() || "—";
|
||||
|
||||
|
|
@ -121,8 +169,6 @@ export function IconRail({ className }: IconRailProps) {
|
|||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
|
||||
<nav
|
||||
className="flex min-h-0 flex-1 flex-col items-stretch overflow-y-auto overflow-x-hidden px-1 py-2"
|
||||
aria-label="Main"
|
||||
|
|
@ -136,6 +182,11 @@ export function IconRail({ className }: IconRailProps) {
|
|||
<button
|
||||
type="button"
|
||||
disabled={!workspaceSegment}
|
||||
aria-label={
|
||||
item.id === "chat" && badge.dot
|
||||
? `Chat, ${badge.count ? `${badge.count} mentions and unread direct messages` : "unread messages"}`
|
||||
: item.label
|
||||
}
|
||||
onClick={() => push(item.segment)}
|
||||
className={cn(
|
||||
"w-full cursor-pointer rounded-lg px-1 py-2 transition-colors",
|
||||
|
|
@ -145,7 +196,23 @@ export function IconRail({ className }: IconRailProps) {
|
|||
: "text-white/70 hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" aria-hidden />
|
||||
<span className="relative">
|
||||
<Icon className="h-5 w-5 shrink-0" aria-hidden />
|
||||
{item.id === "chat" && badge.dot && (
|
||||
<span
|
||||
className="absolute -right-1 -top-1 size-2 rounded-full bg-rose-400"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{item.id === "chat" && badge.count > 0 && (
|
||||
<span
|
||||
className="rounded-full bg-rose-500 px-1 text-[10px] text-white"
|
||||
aria-label={`${badge.count} mentions and unread direct messages`}
|
||||
>
|
||||
{badge.count > 99 ? "99+" : badge.count}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"max-w-[52px] truncate text-center text-[10px] font-medium leading-tight",
|
||||
|
|
|
|||
123
apps/web/lib/use-chat-subscription.ts
Normal file
123
apps/web/lib/use-chat-subscription.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getCollabUrl } from "@/lib/yjs-provider";
|
||||
|
||||
/** Invalidation-only transport. Durable chat history must be fetched through the authenticated API. */
|
||||
export function useChatSubscription(
|
||||
workspace: string,
|
||||
channelIds: readonly string[],
|
||||
onHint?: () => void,
|
||||
): void {
|
||||
const hint = useRef(onHint);
|
||||
hint.current = onHint;
|
||||
const queryClient = useQueryClient();
|
||||
const channels = [...new Set(channelIds)].sort().join(",");
|
||||
useEffect(() => {
|
||||
if (!workspace || !channels) return;
|
||||
let socket: WebSocket | undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let stopped = false;
|
||||
let attempts = 0;
|
||||
let revoked = false;
|
||||
const invalidate = () => {
|
||||
if (stopped) return;
|
||||
hint.current?.();
|
||||
void queryClient.invalidateQueries({ predicate: (query) => {
|
||||
const [path, options] = query.queryKey;
|
||||
return Array.isArray(path) && path[0] === "chat" &&
|
||||
!!options && typeof options === "object" && "input" in options &&
|
||||
!!options.input && typeof options.input === "object" &&
|
||||
"workspace" in options.input && options.input.workspace === workspace;
|
||||
} });
|
||||
};
|
||||
async function connect() {
|
||||
revoked = false;
|
||||
try {
|
||||
const response = await fetch("/api/chat/ticket", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ workspace }),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (
|
||||
response.status === 401 ||
|
||||
response.status === 403 ||
|
||||
response.status === 404
|
||||
)
|
||||
return;
|
||||
throw new Error("Chat ticket unavailable");
|
||||
}
|
||||
const data: unknown = await response.json();
|
||||
if (
|
||||
!data ||
|
||||
typeof data !== "object" ||
|
||||
!("ticket" in data) ||
|
||||
typeof data.ticket !== "string"
|
||||
)
|
||||
throw new Error("Invalid chat ticket");
|
||||
if (stopped) return;
|
||||
const url = new URL(
|
||||
process.env.NEXT_PUBLIC_CHAT_SERVER_URL || getCollabUrl(),
|
||||
);
|
||||
if (!process.env.NEXT_PUBLIC_CHAT_SERVER_URL) url.port = "1235";
|
||||
url.pathname = "/chat";
|
||||
url.search = new URLSearchParams({ ticket: data.ticket }).toString();
|
||||
socket = new WebSocket(url);
|
||||
socket.onopen = () => {
|
||||
attempts = 0;
|
||||
invalidate(); // catch up after missed Redis notifications
|
||||
for (const channelId of channels.split(","))
|
||||
socket?.send(JSON.stringify({ type: "subscribe", channelId }));
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const message: unknown = JSON.parse(String(event.data));
|
||||
if (
|
||||
message &&
|
||||
typeof message === "object" &&
|
||||
"type" in message &&
|
||||
(message.type === "invalidate" ||
|
||||
message.type === "revoked" ||
|
||||
message.type === "subscribed")
|
||||
) {
|
||||
if (message.type === "revoked") revoked = true;
|
||||
invalidate();
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed hints */
|
||||
}
|
||||
};
|
||||
socket.onclose = (event) => {
|
||||
if (event.code === 1008 && revoked) {
|
||||
invalidate();
|
||||
}
|
||||
if (!stopped)
|
||||
timer = setTimeout(
|
||||
() => {
|
||||
void connect();
|
||||
},
|
||||
Math.min(30_000, 1000 * 2 ** Math.min(attempts++, 5)),
|
||||
);
|
||||
};
|
||||
} catch {
|
||||
if (!stopped)
|
||||
timer = setTimeout(
|
||||
() => {
|
||||
void connect();
|
||||
},
|
||||
Math.min(30_000, 1000 * 2 ** Math.min(attempts++, 5)),
|
||||
);
|
||||
}
|
||||
}
|
||||
void connect();
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
socket?.close();
|
||||
};
|
||||
}, [workspace, channels, queryClient]);
|
||||
}
|
||||
|
|
@ -7,7 +7,9 @@
|
|||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"type-check": "tsc --noEmit"
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^1",
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ import { convoysRouter } from "@/server/routers/convoys";
|
|||
import { copilotRouter, opsCopilotRouter } from "@/server/routers/copilot";
|
||||
import { harnessesRouter } from "@/server/routers/harnesses";
|
||||
import { budgetsRouter } from "@/server/routers/budgets";
|
||||
import { chatRouter } from "@/server/routers/chat";
|
||||
|
||||
export const appRouter = router({
|
||||
health: healthRouter,
|
||||
chat: chatRouter,
|
||||
workspaces: workspacesRouter,
|
||||
types: typesRouter,
|
||||
objects: objectsRouter,
|
||||
|
|
|
|||
116
apps/web/server/routers/chat.test.ts
Normal file
116
apps/web/server/routers/chat.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { db } from "@tasks/database";
|
||||
import type { Session } from "next-auth";
|
||||
import {
|
||||
ChatMutationError,
|
||||
mutateChatMessage,
|
||||
createMessageMutationRunner,
|
||||
chatMutationCatchUp,
|
||||
} from "@tasks/database/chat";
|
||||
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
|
||||
import { createCallerFactory } from "@/server/trpc";
|
||||
import { chatRouter } from "./chat";
|
||||
|
||||
vi.mock("@tasks/database/chat", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@tasks/database/chat")>()),
|
||||
mutateChatMessage: vi.fn(),
|
||||
chatMutationCatchUp: vi.fn(),
|
||||
createMessageMutationRunner: vi.fn(() => ({ transaction: vi.fn() })),
|
||||
}));
|
||||
vi.mock("@/server/lib/resolve-workspace", () => ({
|
||||
resolveWorkspace: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||
|
||||
const workspaceId = "11111111-1111-4111-8111-111111111111";
|
||||
const channelId = "33333333-3333-4333-8333-333333333333";
|
||||
const userId = "44444444-4444-4444-8444-444444444444";
|
||||
const messageId = "77777777-7777-4777-8777-777777777777";
|
||||
const clientMutationId = "66666666-6666-4666-8666-666666666666";
|
||||
const caller = () =>
|
||||
createCallerFactory(chatRouter)({
|
||||
db,
|
||||
session: { user: { id: userId } } as Session,
|
||||
});
|
||||
const command = {
|
||||
type: "edit_message" as const,
|
||||
workspaceId,
|
||||
channelId,
|
||||
context: { type: "workspace" as const, workspaceId },
|
||||
messageId,
|
||||
body: "corrected",
|
||||
expectedRevision: 2,
|
||||
clientMutationId,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(resolveWorkspace).mockResolvedValue({
|
||||
id: workspaceId,
|
||||
slug: "acme",
|
||||
name: "Acme",
|
||||
role: "member",
|
||||
ownerUserId: userId,
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat message mutation procedures", () => {
|
||||
it("delegates validated edit and delete with actor and exact command", async () => {
|
||||
const result = {
|
||||
changed: true,
|
||||
result: { messageId, revision: 3, status: "edited" as const },
|
||||
};
|
||||
vi.mocked(mutateChatMessage).mockResolvedValue(result);
|
||||
await expect(caller().editMessage({ workspace: workspaceId, command })).resolves.toEqual(result);
|
||||
expect(mutateChatMessage).toHaveBeenCalledWith(expect.anything(), {
|
||||
actorUserId: userId,
|
||||
command,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mutation recovery procedure", () => {
|
||||
const input = { workspace: "acme", channelId, after: "0", limit: 2 };
|
||||
const result = {
|
||||
mutations: [{ messageId, mutationSequence: "1", revision: 3, kind: "edited" as const }],
|
||||
next: "1",
|
||||
through: "3",
|
||||
hasMore: true,
|
||||
};
|
||||
|
||||
it("passes the resolved workspace and session actor, and preserves the fixed high water", async () => {
|
||||
vi.mocked(chatMutationCatchUp).mockResolvedValue(result as Awaited<ReturnType<typeof chatMutationCatchUp>>);
|
||||
await expect(caller().mutationCatchUp(input)).resolves.toEqual(result);
|
||||
expect(chatMutationCatchUp).toHaveBeenCalledWith(db, {
|
||||
workspaceId,
|
||||
actorUserId: userId,
|
||||
channelId,
|
||||
after: "0",
|
||||
through: undefined,
|
||||
limit: 2,
|
||||
});
|
||||
await caller().mutationCatchUp({ ...input, after: "1", through: "3" });
|
||||
expect(chatMutationCatchUp).toHaveBeenLastCalledWith(db, {
|
||||
workspaceId,
|
||||
actorUserId: userId,
|
||||
channelId,
|
||||
after: "1",
|
||||
through: "3",
|
||||
limit: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed cursors and maps read denial without exposing a channel", async () => {
|
||||
await expect(caller().mutationCatchUp({ ...input, after: "01" })).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
await expect(caller().mutationCatchUp({ ...input, through: "0", after: "1" })).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(chatMutationCatchUp).not.toHaveBeenCalled();
|
||||
vi.mocked(chatMutationCatchUp).mockRejectedValueOnce(new (await import("@tasks/database/chat")).ChatReadError());
|
||||
await expect(caller().mutationCatchUp(input)).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("requires an authenticated workspace member", async () => {
|
||||
vi.mocked(resolveWorkspace).mockRejectedValueOnce(new Error("not a member"));
|
||||
await expect(caller().mutationCatchUp(input)).rejects.toThrow();
|
||||
expect(chatMutationCatchUp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
426
apps/web/server/routers/chat.ts
Normal file
426
apps/web/server/routers/chat.ts
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
import { TRPCError } from "@trpc/server";
|
||||
import {
|
||||
chatChannelNameSchema,
|
||||
chatClientMutationIdSchema,
|
||||
chatCommandSchema,
|
||||
chatCreateChannelInputSchema,
|
||||
chatCreateDirectInputSchema,
|
||||
type ChatCommand,
|
||||
} from "@tasks/shared";
|
||||
import {
|
||||
addPrivateMember,
|
||||
archiveChannel,
|
||||
ChannelPersistenceError,
|
||||
ChatPersistenceError,
|
||||
ChatMutationError,
|
||||
ChatReadError,
|
||||
chatHistory,
|
||||
chatThreadHistory,
|
||||
chatCatchUp,
|
||||
chatMutationCatchUp,
|
||||
chatMarkChannelRead,
|
||||
chatUnreadSummary,
|
||||
chatSequenceSchema,
|
||||
chatCursorSchema,
|
||||
chatPageSizeSchema,
|
||||
chatCatchUpSizeSchema,
|
||||
createChannel,
|
||||
createChannelTransactionRunner,
|
||||
createChatTransactionRunner,
|
||||
createDirectChannel,
|
||||
listVisibleChannels,
|
||||
createMessageMutationRunner,
|
||||
mutateChatMessage,
|
||||
removePrivateMember,
|
||||
renameChannel,
|
||||
sendChatMessage,
|
||||
} from "@tasks/database/chat";
|
||||
import { z } from "zod";
|
||||
|
||||
import { router, workspaceProcedure } from "@/server/trpc";
|
||||
|
||||
const sendCommandSchema = chatCommandSchema.refine(
|
||||
(command) => command.type === "send_message",
|
||||
{ message: "Expected a send_message command" },
|
||||
);
|
||||
const mutationCommandSchema = chatCommandSchema.refine(
|
||||
(command) =>
|
||||
command.type === "edit_message" || command.type === "delete_message",
|
||||
{ message: "Expected a message mutation" },
|
||||
);
|
||||
const mutationInputSchema = z
|
||||
.object({
|
||||
workspace: z.string().min(1),
|
||||
command: mutationCommandSchema,
|
||||
})
|
||||
.strict();
|
||||
const inputSchema = z
|
||||
.object({
|
||||
workspace: z.string().min(1),
|
||||
command: sendCommandSchema,
|
||||
mentionedUserIds: z.array(z.string().uuid()).max(100).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function mapPersistenceError(error: unknown): never {
|
||||
if (error instanceof ChatPersistenceError) {
|
||||
if (error.code === "CHAT_FORBIDDEN") {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Chat channel not found",
|
||||
});
|
||||
}
|
||||
if (error.code === "CHAT_IDEMPOTENCY_CONFLICT") {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Message request conflicts with an existing request",
|
||||
});
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid chat message request",
|
||||
});
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Unable to send chat message",
|
||||
});
|
||||
}
|
||||
|
||||
const workspaceInput = z.object({ workspace: z.string().min(1) }).strict();
|
||||
const channelInput = workspaceInput
|
||||
.extend({
|
||||
channelId: z.string().uuid(),
|
||||
clientMutationId: chatClientMutationIdSchema,
|
||||
})
|
||||
.strict();
|
||||
const runChannels = (
|
||||
db: Parameters<typeof createChannelTransactionRunner>[0],
|
||||
) => createChannelTransactionRunner(db);
|
||||
async function channelCall<T>(operation: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (error instanceof ChannelPersistenceError) {
|
||||
if (error.code === "NOT_FOUND")
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Chat channel not found",
|
||||
});
|
||||
if (error.code === "CONFLICT")
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Channel conflicts with existing state",
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error.code === "READ_ONLY"
|
||||
? "Channel is archived"
|
||||
: "Invalid channel request",
|
||||
});
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Unable to update chat channel",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const readScope = workspaceInput
|
||||
.extend({ channelId: z.string().uuid() })
|
||||
.strict();
|
||||
const catchUpInput = readScope
|
||||
.extend({
|
||||
after: chatCursorSchema,
|
||||
through: chatCursorSchema.optional(),
|
||||
limit: chatCatchUpSizeSchema.default(100),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(input) =>
|
||||
input.through === undefined ||
|
||||
BigInt(input.after) <= BigInt(input.through),
|
||||
{ path: ["through"], message: "After exceeds through" },
|
||||
);
|
||||
const pageInput = readScope
|
||||
.extend({
|
||||
before: chatSequenceSchema.optional(),
|
||||
limit: chatPageSizeSchema.default(50),
|
||||
})
|
||||
.strict();
|
||||
async function readCall<T>(operation: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (error instanceof ChatReadError)
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Chat channel not found",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function mutationCall(
|
||||
db: Parameters<typeof createMessageMutationRunner>[0],
|
||||
actorUserId: string,
|
||||
workspaceId: string,
|
||||
command: Extract<ChatCommand, { type: "edit_message" | "delete_message" }>,
|
||||
) {
|
||||
if (
|
||||
command.workspaceId !== workspaceId ||
|
||||
command.context.workspaceId !== workspaceId
|
||||
)
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace mismatch" });
|
||||
try {
|
||||
return await mutateChatMessage(createMessageMutationRunner(db), {
|
||||
actorUserId,
|
||||
command,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ChatMutationError) {
|
||||
if (error.code === "CHAT_FORBIDDEN" || error.code === "CHAT_NOT_FOUND")
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Chat message not found",
|
||||
});
|
||||
if (error.code === "CHAT_REVISION_CONFLICT")
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Message changed. Refresh before trying again.",
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Message request conflicts with an existing request",
|
||||
});
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Unable to update chat message",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const chatRouter = router({
|
||||
viewer: workspaceProcedure
|
||||
.input(workspaceInput)
|
||||
.query(({ ctx }) => ({ role: ctx.workspace.role })),
|
||||
// History and reconnect endpoints use one workspace-resolved, membership-checked read seam.
|
||||
history: workspaceProcedure.input(pageInput).query(({ ctx, input }) =>
|
||||
readCall(() =>
|
||||
chatHistory(ctx.db, {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
channelId: input.channelId,
|
||||
before: input.before,
|
||||
limit: input.limit,
|
||||
}),
|
||||
),
|
||||
),
|
||||
threadHistory: workspaceProcedure
|
||||
.input(pageInput.extend({ rootMessageId: z.string().uuid() }).strict())
|
||||
.query(({ ctx, input }) =>
|
||||
readCall(() =>
|
||||
chatThreadHistory(ctx.db, {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
channelId: input.channelId,
|
||||
rootMessageId: input.rootMessageId,
|
||||
before: input.before,
|
||||
limit: input.limit,
|
||||
}),
|
||||
),
|
||||
),
|
||||
catchUp: workspaceProcedure
|
||||
.input(catchUpInput)
|
||||
.query(({ ctx, input }) =>
|
||||
readCall(() =>
|
||||
chatCatchUp(ctx.db, {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
channelId: input.channelId,
|
||||
after: input.after,
|
||||
through: input.through,
|
||||
limit: input.limit,
|
||||
}),
|
||||
),
|
||||
),
|
||||
mutationCatchUp: workspaceProcedure
|
||||
.input(catchUpInput)
|
||||
.query(({ ctx, input }) =>
|
||||
readCall(() =>
|
||||
chatMutationCatchUp(ctx.db, {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
channelId: input.channelId,
|
||||
after: input.after,
|
||||
through: input.through,
|
||||
limit: input.limit,
|
||||
}),
|
||||
),
|
||||
),
|
||||
markChannelRead: workspaceProcedure
|
||||
.input(readScope.extend({ sequence: chatSequenceSchema }).strict())
|
||||
.mutation(({ ctx, input }) =>
|
||||
readCall(() =>
|
||||
chatMarkChannelRead(ctx.db, {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
channelId: input.channelId,
|
||||
sequence: input.sequence,
|
||||
}),
|
||||
),
|
||||
),
|
||||
unreadSummary: workspaceProcedure.input(workspaceInput).query(({ ctx }) =>
|
||||
readCall(() =>
|
||||
chatUnreadSummary(ctx.db, {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
}),
|
||||
),
|
||||
),
|
||||
listChannels: workspaceProcedure.input(workspaceInput).query(({ ctx }) =>
|
||||
channelCall(() =>
|
||||
listVisibleChannels(runChannels(ctx.db), {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
}),
|
||||
),
|
||||
),
|
||||
createChannel: workspaceProcedure
|
||||
.input(chatCreateChannelInputSchema)
|
||||
.mutation(({ ctx, input }) =>
|
||||
channelCall(() =>
|
||||
createChannel(runChannels(ctx.db), {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
kind: input.kind,
|
||||
name: input.name,
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
renameChannel: workspaceProcedure
|
||||
.input(channelInput.extend({ name: chatChannelNameSchema }).strict())
|
||||
.mutation(({ ctx, input }) =>
|
||||
channelCall(() =>
|
||||
renameChannel(runChannels(ctx.db), {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
channelId: input.channelId,
|
||||
name: input.name,
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
archiveChannel: workspaceProcedure
|
||||
.input(channelInput)
|
||||
.mutation(({ ctx, input }) =>
|
||||
channelCall(() =>
|
||||
archiveChannel(runChannels(ctx.db), {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
channelId: input.channelId,
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
addPrivateMember: workspaceProcedure
|
||||
.input(channelInput.extend({ userId: z.string().uuid() }).strict())
|
||||
.mutation(({ ctx, input }) =>
|
||||
channelCall(() =>
|
||||
addPrivateMember(runChannels(ctx.db), {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
channelId: input.channelId,
|
||||
userId: input.userId,
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
removePrivateMember: workspaceProcedure
|
||||
.input(channelInput.extend({ userId: z.string().uuid() }).strict())
|
||||
.mutation(({ ctx, input }) =>
|
||||
channelCall(() =>
|
||||
removePrivateMember(runChannels(ctx.db), {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
channelId: input.channelId,
|
||||
userId: input.userId,
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
createDirect: workspaceProcedure
|
||||
.input(chatCreateDirectInputSchema)
|
||||
.mutation(({ ctx, input }) =>
|
||||
channelCall(() =>
|
||||
createDirectChannel(runChannels(ctx.db), {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
participantUserIds: input.participantUserIds,
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
editMessage: workspaceProcedure
|
||||
.input(mutationInputSchema)
|
||||
.mutation(({ ctx, input }) => {
|
||||
if (input.command.type !== "edit_message")
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Expected edit_message command",
|
||||
});
|
||||
return mutationCall(
|
||||
ctx.db,
|
||||
ctx.session.user.id,
|
||||
ctx.workspace.id,
|
||||
input.command,
|
||||
);
|
||||
}),
|
||||
deleteMessage: workspaceProcedure
|
||||
.input(mutationInputSchema)
|
||||
.mutation(({ ctx, input }) => {
|
||||
if (input.command.type !== "delete_message")
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Expected delete_message command",
|
||||
});
|
||||
return mutationCall(
|
||||
ctx.db,
|
||||
ctx.session.user.id,
|
||||
ctx.workspace.id,
|
||||
input.command,
|
||||
);
|
||||
}),
|
||||
send: workspaceProcedure
|
||||
.input(inputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const command = {
|
||||
...input.command,
|
||||
workspaceId: ctx.workspace.id,
|
||||
context: { type: "workspace" as const, workspaceId: ctx.workspace.id },
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await sendChatMessage(
|
||||
createChatTransactionRunner(ctx.db),
|
||||
{
|
||||
actorUserId: ctx.session.user.id,
|
||||
command,
|
||||
mentionedUserIds: input.mentionedUserIds ?? [],
|
||||
},
|
||||
);
|
||||
return {
|
||||
created: result.created,
|
||||
message: {
|
||||
...result.message,
|
||||
sequence: result.message.sequence.toString(),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
mapPersistenceError(error);
|
||||
}
|
||||
}),
|
||||
});
|
||||
15
apps/web/vitest.config.ts
Normal file
15
apps/web/vitest.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: { "@": fileURLToPath(new URL("./", import.meta.url)) },
|
||||
},
|
||||
esbuild: { jsx: "automatic" },
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["**/*.test.{ts,tsx}"],
|
||||
exclude: ["node_modules/**", ".next/**"],
|
||||
passWithNoTests: false,
|
||||
},
|
||||
});
|
||||
268
apps/worker/src/chat/outbox.test.ts
Normal file
268
apps/worker/src/chat/outbox.test.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
chatOutboxChannel,
|
||||
createChatOutboxClaim,
|
||||
publishChatOutboxBatch,
|
||||
runChatOutboxPublisher,
|
||||
type ChatOutboxRow,
|
||||
type ClaimBatch,
|
||||
} from "./outbox.js";
|
||||
|
||||
const workspaceId = "00000000-0000-4000-8000-000000000001";
|
||||
const channelId = "00000000-0000-4000-8000-000000000002";
|
||||
const row: ChatOutboxRow = {
|
||||
id: "00000000-0000-4000-8000-000000000003",
|
||||
workspaceId,
|
||||
channelId,
|
||||
eventType: "chat.read_state.changed.v1",
|
||||
eventVersion: 1,
|
||||
dedupeKey: "read:1",
|
||||
payload: {
|
||||
eventType: "chat.read_state.changed.v1",
|
||||
eventVersion: 1,
|
||||
workspaceId,
|
||||
channelId,
|
||||
userId: workspaceId,
|
||||
sequence: "1",
|
||||
},
|
||||
};
|
||||
|
||||
type Mark = (
|
||||
id: string,
|
||||
status: "published" | "failed",
|
||||
error?: string,
|
||||
) => Promise<void>;
|
||||
function harness(rows: ChatOutboxRow[]) {
|
||||
const changes: Array<{
|
||||
id: string;
|
||||
status: "published" | "failed";
|
||||
error?: string;
|
||||
}> = [];
|
||||
const claim: ClaimBatch = vi.fn(async (run) =>
|
||||
run(
|
||||
rows,
|
||||
async (id: string, status: "published" | "failed", error?: string) => {
|
||||
changes.push({ id, status, error });
|
||||
},
|
||||
),
|
||||
);
|
||||
const publish = vi.fn(async (_channel: string, _payload: string) => 1);
|
||||
return { claim, publish, changes };
|
||||
}
|
||||
|
||||
const flush = async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("chat outbox publisher", () => {
|
||||
it("claims a batch and publishes the committed envelope on a tenant/channel scoped topic", async () => {
|
||||
const h = harness([row]);
|
||||
expect(await publishChatOutboxBatch(h.claim, h.publish)).toEqual({
|
||||
published: 1,
|
||||
failed: 0,
|
||||
});
|
||||
expect(h.publish).toHaveBeenCalledWith(
|
||||
chatOutboxChannel(workspaceId, channelId),
|
||||
JSON.stringify({
|
||||
...row.payload,
|
||||
id: row.id,
|
||||
dedupeKey: row.dedupeKey,
|
||||
workspaceId,
|
||||
channelId,
|
||||
eventType: row.eventType,
|
||||
eventVersion: row.eventVersion,
|
||||
}),
|
||||
);
|
||||
expect(h.changes).toEqual([
|
||||
{ id: row.id, status: "published", error: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("records a failed publish and continues processing other rows", async () => {
|
||||
const second = { ...row, id: "00000000-0000-4000-8000-000000000004" };
|
||||
const h = harness([row, second]);
|
||||
h.publish.mockRejectedValueOnce(new Error("redis down"));
|
||||
expect(await publishChatOutboxBatch(h.claim, h.publish)).toEqual({
|
||||
published: 1,
|
||||
failed: 1,
|
||||
});
|
||||
expect(h.changes).toEqual([
|
||||
{ id: row.id, status: "failed", error: "redis down" },
|
||||
{ id: second.id, status: "published", error: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not publish when claim fails", async () => {
|
||||
const h = harness([]);
|
||||
vi.mocked(h.claim).mockRejectedValueOnce(new Error("postgres down"));
|
||||
await expect(publishChatOutboxBatch(h.claim, h.publish)).rejects.toThrow(
|
||||
"postgres down",
|
||||
);
|
||||
expect(h.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("times out a hung publish without marking published even if Redis later acknowledges", async () => {
|
||||
vi.useFakeTimers();
|
||||
let acknowledge!: (count: number) => void;
|
||||
const publish = vi.fn(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
acknowledge = resolve;
|
||||
}),
|
||||
);
|
||||
const h = harness([row]);
|
||||
const batch = publishChatOutboxBatch(h.claim, publish, {
|
||||
publishTimeoutMs: 100,
|
||||
});
|
||||
await flush();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(await batch).toEqual({ published: 0, failed: 1 });
|
||||
acknowledge(1);
|
||||
await flush();
|
||||
expect(h.changes).toEqual([
|
||||
{
|
||||
id: row.id,
|
||||
status: "failed",
|
||||
error: expect.stringContaining("timed out"),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops starting new publishes on abort and leaves the pending row retryable", async () => {
|
||||
const controller = new AbortController();
|
||||
const h = harness([
|
||||
row,
|
||||
{ ...row, id: "00000000-0000-4000-8000-000000000004" },
|
||||
]);
|
||||
const publish = vi.fn(() => new Promise<number>(() => {}));
|
||||
const batch = publishChatOutboxBatch(h.claim, publish, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
await flush();
|
||||
controller.abort();
|
||||
expect(await batch).toEqual({ published: 0, failed: 1 });
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
expect(h.changes[0]).toMatchObject({ id: row.id, status: "failed" });
|
||||
});
|
||||
|
||||
it("leaves a published notification pending after transaction rollback and redelivers with the same dedupe id", async () => {
|
||||
const pending = new Set([row.id]);
|
||||
let rollback = true;
|
||||
const claim: ClaimBatch = async (run) => {
|
||||
const staged = new Set<string>();
|
||||
await run(pending.has(row.id) ? [row] : [], async (id, status) => {
|
||||
if (status === "published") staged.add(id);
|
||||
});
|
||||
if (rollback) throw new Error("commit failed");
|
||||
for (const id of staged) pending.delete(id);
|
||||
};
|
||||
const publish = vi.fn(async (_channel: string, _payload: string) => 1);
|
||||
await expect(publishChatOutboxBatch(claim, publish)).rejects.toThrow(
|
||||
"commit failed",
|
||||
);
|
||||
rollback = false;
|
||||
expect(await publishChatOutboxBatch(claim, publish)).toEqual({
|
||||
published: 1,
|
||||
failed: 0,
|
||||
});
|
||||
expect(publish).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.parse(publish.mock.calls[0]![1]!)).toMatchObject({
|
||||
id: row.id,
|
||||
dedupeKey: row.dedupeKey,
|
||||
});
|
||||
expect(publish.mock.calls[1]).toEqual(publish.mock.calls[0]);
|
||||
});
|
||||
|
||||
it("builds a skip-locked ordered claim and tenant/channel scoped update without a live DB", async () => {
|
||||
const dialect = new PgDialect();
|
||||
const queries: string[] = [];
|
||||
const tx = {
|
||||
select: vi.fn(() => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
orderBy: (...columns: unknown[]) => {
|
||||
queries.push(`order:${columns.length}`);
|
||||
return {
|
||||
limit: (size: number) => ({
|
||||
for: (_mode: string, _options: unknown) => {
|
||||
queries.push(
|
||||
`limit:${size}:${_mode}:${JSON.stringify(_options)}`,
|
||||
);
|
||||
return Promise.resolve([row]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})),
|
||||
update: vi.fn(() => ({
|
||||
set: () => ({
|
||||
where: (condition: unknown) => {
|
||||
queries.push(
|
||||
dialect.sqlToQuery(
|
||||
condition as Parameters<typeof dialect.sqlToQuery>[0],
|
||||
).sql,
|
||||
);
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
})),
|
||||
};
|
||||
type FakeTx = typeof tx;
|
||||
const db = {
|
||||
transaction: async (run: (transaction: FakeTx) => Promise<void>) =>
|
||||
run(tx),
|
||||
};
|
||||
await createChatOutboxClaim(db as never)(async (rows, mark) => {
|
||||
expect(rows).toEqual([row]);
|
||||
await mark(row.id, "published");
|
||||
});
|
||||
expect(queries.slice(0, 2)).toEqual([
|
||||
"order:2",
|
||||
'limit:50:update:{"skipLocked":true}',
|
||||
]);
|
||||
expect(queries[2]).toContain("workspace_id");
|
||||
expect(queries[2]).toContain("channel_id");
|
||||
expect(queries[2]).toContain("published_at");
|
||||
await expect(
|
||||
createChatOutboxClaim(db as never)(async (_rows, mark) => {
|
||||
await mark("unclaimed", "published");
|
||||
}),
|
||||
).rejects.toThrow("unclaimed");
|
||||
});
|
||||
|
||||
it("paces retries after failures and exits promptly on abort during the delay", async () => {
|
||||
vi.useFakeTimers();
|
||||
const controller = new AbortController();
|
||||
const claim = vi.fn(
|
||||
async (run: (rows: ChatOutboxRow[], mark: Mark) => Promise<void>) => {
|
||||
await run([row], async () => {});
|
||||
},
|
||||
);
|
||||
const redis = {
|
||||
publish: vi.fn(async () => {
|
||||
throw new Error("redis down");
|
||||
}),
|
||||
};
|
||||
const running = runChatOutboxPublisher(
|
||||
{} as Parameters<typeof runChatOutboxPublisher>[0],
|
||||
redis as never,
|
||||
controller.signal,
|
||||
claim,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(900);
|
||||
expect(redis.publish).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(redis.publish).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(1900);
|
||||
expect(redis.publish).toHaveBeenCalledTimes(2);
|
||||
controller.abort();
|
||||
await running;
|
||||
expect(redis.publish).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
218
apps/worker/src/chat/outbox.ts
Normal file
218
apps/worker/src/chat/outbox.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { and, asc, eq, isNull, sql } from "drizzle-orm";
|
||||
import type IoRedis from "ioredis";
|
||||
|
||||
import type { db as dbInstance } from "@tasks/database";
|
||||
import { chatOutbox } from "@tasks/database/schema";
|
||||
|
||||
/** Redis is a notification transport, not a durable history. Consumers replay from Postgres. */
|
||||
export function chatOutboxChannel(
|
||||
workspaceId: string,
|
||||
channelId: string,
|
||||
): string {
|
||||
return `chat:v1:workspace:${workspaceId}:channel:${channelId}`;
|
||||
}
|
||||
|
||||
export type ChatOutboxRow = Pick<
|
||||
typeof chatOutbox.$inferSelect,
|
||||
| "id"
|
||||
| "workspaceId"
|
||||
| "channelId"
|
||||
| "eventType"
|
||||
| "eventVersion"
|
||||
| "dedupeKey"
|
||||
| "payload"
|
||||
>;
|
||||
type Mark = (
|
||||
id: string,
|
||||
status: "published" | "failed",
|
||||
error?: string,
|
||||
) => Promise<void>;
|
||||
export type ClaimBatch = (
|
||||
run: (rows: ChatOutboxRow[], mark: Mark) => Promise<void>,
|
||||
) => Promise<void>;
|
||||
type Publish = (channel: string, payload: string) => Promise<number>;
|
||||
|
||||
const PUBLISH_TIMEOUT_MS = 5_000;
|
||||
const RETRY_MIN_MS = 1_000;
|
||||
const RETRY_MAX_MS = 30_000;
|
||||
|
||||
function abortError(): Error {
|
||||
return new Error("Chat outbox publish aborted");
|
||||
}
|
||||
|
||||
/** Do not disconnect the shared Redis client: it also handles orchestrator jobs. */
|
||||
async function boundedPublish(
|
||||
publish: Publish,
|
||||
channel: string,
|
||||
payload: string,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) throw abortError();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let onAbort: (() => void) | undefined;
|
||||
const deadline = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error("Chat outbox publish timed out")),
|
||||
timeoutMs,
|
||||
);
|
||||
if (signal) {
|
||||
onAbort = () => reject(abortError());
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal.aborted) onAbort();
|
||||
}
|
||||
});
|
||||
try {
|
||||
// A late Redis acknowledgement may mean duplicate delivery on retry. Keep the
|
||||
// outbox row pending and let consumers deduplicate by id/dedupeKey.
|
||||
await Promise.race([
|
||||
Promise.resolve().then(() => publish(channel, payload)),
|
||||
deadline,
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
function pause(ms: number, signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
const finish = () => {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener("abort", finish);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(finish, ms);
|
||||
signal.addEventListener("abort", finish, { once: true });
|
||||
if (signal.aborted) finish();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the row locks until Redis acknowledges the publish, so concurrent workers
|
||||
* cannot send the same pending row. A crash between publish and commit can still
|
||||
* redeliver it: consumers must deduplicate by id and replay by channel cursor.
|
||||
*/
|
||||
export async function publishChatOutboxBatch(
|
||||
claim: ClaimBatch,
|
||||
publish: Publish,
|
||||
options: { publishTimeoutMs?: number; signal?: AbortSignal } = {},
|
||||
): Promise<{ published: number; failed: number }> {
|
||||
const result = { published: 0, failed: 0 };
|
||||
await claim(async (rows, mark) => {
|
||||
for (const row of rows) {
|
||||
if (options.signal?.aborted) break;
|
||||
let failure: unknown;
|
||||
try {
|
||||
await boundedPublish(
|
||||
publish,
|
||||
chatOutboxChannel(row.workspaceId, row.channelId),
|
||||
JSON.stringify({
|
||||
...row.payload,
|
||||
id: row.id,
|
||||
dedupeKey: row.dedupeKey,
|
||||
workspaceId: row.workspaceId,
|
||||
channelId: row.channelId,
|
||||
eventType: row.eventType,
|
||||
eventVersion: row.eventVersion,
|
||||
}),
|
||||
options.publishTimeoutMs ?? PUBLISH_TIMEOUT_MS,
|
||||
options.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
if (failure !== undefined) {
|
||||
const message =
|
||||
failure instanceof Error ? failure.message : String(failure);
|
||||
// A failed status is still pending; it will be retried on the next tick.
|
||||
await mark(row.id, "failed", message.slice(0, 2000));
|
||||
result.failed++;
|
||||
} else {
|
||||
await mark(row.id, "published");
|
||||
result.published++;
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createChatOutboxClaim(
|
||||
db: typeof dbInstance,
|
||||
batchSize = 50,
|
||||
): ClaimBatch {
|
||||
return async (run) => {
|
||||
await db.transaction(async (tx) => {
|
||||
const rows = await tx
|
||||
.select({
|
||||
id: chatOutbox.id,
|
||||
workspaceId: chatOutbox.workspaceId,
|
||||
channelId: chatOutbox.channelId,
|
||||
eventType: chatOutbox.eventType,
|
||||
eventVersion: chatOutbox.eventVersion,
|
||||
dedupeKey: chatOutbox.dedupeKey,
|
||||
payload: chatOutbox.payload,
|
||||
})
|
||||
.from(chatOutbox)
|
||||
.where(isNull(chatOutbox.publishedAt))
|
||||
.orderBy(asc(chatOutbox.createdAt), asc(chatOutbox.id))
|
||||
.limit(batchSize)
|
||||
.for("update", { skipLocked: true });
|
||||
|
||||
await run(rows, async (id, status, error) => {
|
||||
const row = rows.find((candidate) => candidate.id === id);
|
||||
if (!row) throw new Error("Cannot mark an unclaimed chat outbox row");
|
||||
await tx
|
||||
.update(chatOutbox)
|
||||
.set({
|
||||
attempts: sql`${chatOutbox.attempts} + 1`,
|
||||
publishedAt: status === "published" ? new Date() : null,
|
||||
lastError:
|
||||
status === "failed" ? (error ?? "Unknown publish failure") : null,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatOutbox.id, id),
|
||||
eq(chatOutbox.workspaceId, row.workspaceId),
|
||||
eq(chatOutbox.channelId, row.channelId),
|
||||
isNull(chatOutbox.publishedAt),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export async function runChatOutboxPublisher(
|
||||
db: typeof dbInstance,
|
||||
redis: Pick<IoRedis, "publish">,
|
||||
signal: AbortSignal,
|
||||
claim: ClaimBatch = createChatOutboxClaim(db),
|
||||
): Promise<void> {
|
||||
let retryMs = RETRY_MIN_MS;
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
const result = await publishChatOutboxBatch(
|
||||
claim,
|
||||
(channel, payload) => redis.publish(channel, payload),
|
||||
{ signal },
|
||||
);
|
||||
if (signal.aborted) break;
|
||||
if (result.failed > 0) {
|
||||
console.error(
|
||||
`[worker] chat outbox: ${result.failed} publish failures`,
|
||||
);
|
||||
await pause(retryMs, signal);
|
||||
retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
|
||||
} else {
|
||||
retryMs = RETRY_MIN_MS;
|
||||
await pause(result.published === 50 ? 0 : RETRY_MIN_MS, signal);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[worker] chat outbox tick failed:", error);
|
||||
await pause(retryMs, signal);
|
||||
retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { db } from "@tasks/database";
|
||||
import IoRedis from "ioredis";
|
||||
|
||||
import { runChatOutboxPublisher } from "./chat/outbox.js";
|
||||
import { loadEnv } from "./env.js";
|
||||
import { handleOrchestratorJob } from "./handle-job.js";
|
||||
import { dequeueJob } from "./queue/dequeue.js";
|
||||
|
|
@ -21,10 +22,15 @@ async function main(): Promise<void> {
|
|||
}
|
||||
|
||||
console.info(`[worker] consuming ${env.WORKER_BLPOP_TIMEOUT_S}s BLPOP on orchestrator queue`);
|
||||
const shutdown = new AbortController();
|
||||
process.once("SIGTERM", () => shutdown.abort());
|
||||
process.once("SIGINT", () => shutdown.abort());
|
||||
const publisher = runChatOutboxPublisher(db, redis, shutdown.signal);
|
||||
|
||||
for (;;) {
|
||||
while (!shutdown.signal.aborted) {
|
||||
try {
|
||||
const job = await dequeueJob(redis, env.WORKER_BLPOP_TIMEOUT_S);
|
||||
if (shutdown.signal.aborted) break;
|
||||
if (!job) continue;
|
||||
console.info(`[worker] dequeued job=${job.id} role=${job.roleId} convoy=${job.convoyId}`);
|
||||
await handleOrchestratorJob(db, redis, env, job);
|
||||
|
|
@ -32,6 +38,8 @@ async function main(): Promise<void> {
|
|||
console.error("[worker] loop error:", error);
|
||||
}
|
||||
}
|
||||
await publisher;
|
||||
await redis.quit();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
|
|
|||
|
|
@ -132,12 +132,14 @@ RUN rm -f apps/web/.env apps/web/.env.local apps/web/.env.production apps/web/.e
|
|||
|
||||
# NEXT_PUBLIC_* must be present at build time — Next.js inlines them into the client bundle.
|
||||
ARG NEXT_PUBLIC_COLLAB_SERVER_URL=ws://localhost:1234
|
||||
ARG NEXT_PUBLIC_CHAT_SERVER_URL=ws://localhost:1235
|
||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT=
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
ARG NEXT_PUBLIC_LIBREDESK_URL=
|
||||
ARG NEXT_PUBLIC_LIBREDESK_WIDGET_ID=
|
||||
ARG NEXT_PUBLIC_DIRECTUS_URL=
|
||||
ENV NEXT_PUBLIC_COLLAB_SERVER_URL=${NEXT_PUBLIC_COLLAB_SERVER_URL}
|
||||
ENV NEXT_PUBLIC_CHAT_SERVER_URL=${NEXT_PUBLIC_CHAT_SERVER_URL}
|
||||
ENV NEXT_PUBLIC_UMAMI_SCRIPT=${NEXT_PUBLIC_UMAMI_SCRIPT}
|
||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||
ENV NEXT_PUBLIC_LIBREDESK_URL=${NEXT_PUBLIC_LIBREDESK_URL}
|
||||
|
|
@ -176,7 +178,7 @@ CMD ["node", "apps/web/server.js"]
|
|||
|
||||
|
||||
# ============================================================================
|
||||
# collab — runtime image (Hocuspocus / Yjs websocket)
|
||||
# collab — runtime image (Hocuspocus / Yjs and workspace Chat listeners)
|
||||
# ============================================================================
|
||||
FROM node:${NODE_VERSION}-alpine AS collab
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
|
|
@ -195,10 +197,11 @@ COPY --from=collab-build --chown=collab:nodejs /app/apps/collab-server/node_modu
|
|||
|
||||
USER collab
|
||||
|
||||
EXPOSE 1234
|
||||
EXPOSE 1234 1235
|
||||
|
||||
# Listener liveness only; authenticated delivery is a separate acceptance gate.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD node -e "const p=+(process.env.PORT||1234);require('net').createConnection(p,'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))"
|
||||
CMD node -e "let remaining=2;for(const p of [+(process.env.PORT||1234),+(process.env.CHAT_PORT||1235)]){const s=require('net').createConnection(p,'127.0.0.1');s.setTimeout(3000,()=>process.exit(1));s.on('connect',()=>{s.destroy();if(!--remaining)process.exit(0)}).on('error',()=>process.exit(1))}"
|
||||
|
||||
WORKDIR /app/apps/collab-server
|
||||
CMD ["node", "dist/index.mjs"]
|
||||
|
|
|
|||
|
|
@ -12,3 +12,4 @@ services:
|
|||
collab:
|
||||
ports:
|
||||
- "1234:1234"
|
||||
- "${CHAT_PORT:-1235}:${CHAT_PORT:-1235}"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
# Domains (Dokploy UI or CT 100 Traefik):
|
||||
# web → echodo.stillwell.cloud (port 3000)
|
||||
# collab → collab.echodo.stillwell.cloud (port 1234)
|
||||
# chat → chat.stillwell.cloud (collab service, CHAT_PORT default 1235)
|
||||
# opencode (optional) → opencode.stillwell.cloud (port 4096)
|
||||
#
|
||||
# Build (required on ≤8 GB hosts — parallel export OOMs with exit 255):
|
||||
|
|
@ -34,6 +35,7 @@ services:
|
|||
target: web
|
||||
args:
|
||||
NEXT_PUBLIC_COLLAB_SERVER_URL: ${NEXT_PUBLIC_COLLAB_SERVER_URL:-ws://localhost:1234}
|
||||
NEXT_PUBLIC_CHAT_SERVER_URL: ${NEXT_PUBLIC_CHAT_SERVER_URL:-ws://localhost:1235}
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT: ${NEXT_PUBLIC_UMAMI_SCRIPT:-}
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
|
||||
NEXT_PUBLIC_LIBREDESK_URL: ${NEXT_PUBLIC_LIBREDESK_URL:-}
|
||||
|
|
@ -48,6 +50,7 @@ services:
|
|||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
- CHAT_TICKET_SECRET=${CHAT_TICKET_SECRET:?Set a shared Chat signing secret of at least 32 characters}
|
||||
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- AUTH_SECRET=${AUTH_SECRET}
|
||||
|
|
@ -100,13 +103,16 @@ services:
|
|||
- DATABASE_URL=${DATABASE_URL}
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
- PORT=1234
|
||||
- CHAT_PORT=${CHAT_PORT:-1235}
|
||||
- CHAT_TICKET_SECRET=${CHAT_TICKET_SECRET:?Set a shared Chat signing secret of at least 32 characters}
|
||||
- NODE_ENV=production
|
||||
expose:
|
||||
- "1234"
|
||||
- "${CHAT_PORT:-1235}"
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- 'node -e "const p=+(process.env.PORT||1234);require(''net'').createConnection(p,''127.0.0.1'').on(''connect'',()=>process.exit(0)).on(''error'',()=>process.exit(1))"'
|
||||
- 'node -e "let remaining=2;for(const p of [+(process.env.PORT||1234),+(process.env.CHAT_PORT||1235)]){const s=require(''net'').createConnection(p,''127.0.0.1'');s.setTimeout(3000,()=>process.exit(1));s.on(''connect'',()=>{s.destroy();if(!--remaining)process.exit(0)}).on(''error'',()=>process.exit(1))}"'
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
|
|
|||
65
docs/Current Progress.md
Normal file
65
docs/Current Progress.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Workspace Chat — Current Progress
|
||||
|
||||
Updated: 2026-10-29 (homelab go-live + acceptance).
|
||||
|
||||
## Status
|
||||
|
||||
Chat runtime is live on CT 116; operator reports **14/14 acceptance checks passed**. Operator-confirmed CT 102 `echodo` ledger: **24 entries through `0023`**, with required columns/table verified and all `chat_*` tables owned by `echodo`. An earlier copy of this document said `0022–0024`; no `0024` migration exists in this checkout, so that statement is not treated as verified.
|
||||
|
||||
The deployment evidence below was supplied by the operator and is present in this workspace copy. It was not independently rerun in this reconciliation session; read-only access through PVE stopped at unknown SSH host-key verification.
|
||||
|
||||
## Deployed
|
||||
|
||||
- `CHAT_TICKET_SECRET` generated on CT 116 (≥32), shared by web + collab (sha256 match). Not logged.
|
||||
- `CHAT_PORT=1235`; collab dual listeners 1234 (Hocuspocus) + 1235 (Chat).
|
||||
- Traefik `echodo-chat` → `192.168.68.116:1235` for `chat.echodo.stillwell.cloud` and `chat.stillwell.cloud`.
|
||||
- Public WSS URL baked into web: `wss://chat.stillwell.cloud` (nested `*.echodo.stillwell.cloud` Universal SSL fails publicly).
|
||||
- Compose validated; web/collab/worker rebuilt/recreated; opencode-v2 preserved.
|
||||
|
||||
## Runtime fixes applied during go-live
|
||||
|
||||
1. Ticket Origin check used `request.url` origin (`https://0.0.0.0:3000`) behind Docker — now prefers `x-forwarded-host` / `host` + `x-forwarded-proto`.
|
||||
2. `channel-drizzle-adapter` `row()` spread full DB columns into strict lifecycle receipt schema — project ChannelRecord fields only.
|
||||
3. `redactCreatedOutbox` `jsonb_build_object(...)` params caused `could not determine data type of parameter $1` — set plain JSON payload instead.
|
||||
4. Empty `AUTH_DEV_PASSWORD` blocked credentials acceptance — set on CT 116 (not logged).
|
||||
|
||||
Source under `/opt/submatter` (+ opencode workspace copy) updated for (1–3). Image chunks patched in place for immediate runtime; rebuild web on next deploy to bake permanently.
|
||||
|
||||
## Source reconciliation (2026-10-29)
|
||||
|
||||
- Channel projection and plain-JSON redaction fixes were already present locally; preserved and covered with adapter regression tests.
|
||||
- Local ticket handling now pins the expected origin to configured `AUTH_URL` (or `NEXTAUTH_URL`), not caller-supplied forwarded headers. Production fails closed without a valid configured URL. This is a hardened replacement for the deployed forwarded-header hot patch, not a byte-for-byte copy. Deploy and retest ticket issuance before considering this replacement live.
|
||||
- Canonical browser endpoint is `wss://chat.stillwell.cloud` (socket path `/chat`). The Traefik example retains both hostname routes.
|
||||
- Next deploy must rebuild web from reconciled source; image hot patches are not durable. Re-run ticket issuance, cross-origin rejection and the acceptance suite afterward. No production changes, rebuild, commit or push were performed during this reconciliation.
|
||||
- **Security follow-up:** `AUTH_DEV_PASSWORD` is a shared credentials password for existing user emails, not a per-user password. Remove it after acceptance unless the operator explicitly intends this login mechanism; verify SSO access first and recreate web when removing the environment value. Awaiting operator decision; not changed remotely.
|
||||
- Remaining release gates: full local checks, disposable-Postgres coverage, final review and convoy lifecycle reconciliation. The reported live suite does not prove multi-instance fan-out or Redis outage behavior.
|
||||
|
||||
## Main deployment preparation
|
||||
|
||||
The operator approved sending the reconciled branch to `main` for real-build testing. The branch includes the earlier foundation commit and its AI-shell/backlog changes. The main push triggers `.forgejo/workflows/deploy-homelab.yml` when the runner and deployment secrets are configured; a push alone does not prove deployment success. The deploy script no longer removes orphan containers, preserving separately managed services such as `opencode-v2`. Six static deployment checks cover the configuration and this safety guard.
|
||||
|
||||
## Local validation after reconciliation
|
||||
|
||||
- `pnpm lint`: passed with existing warnings.
|
||||
- `pnpm type-check --concurrency=1`: passed.
|
||||
- `pnpm test`: ticket-origin regressions (11), channel adapter tests (10) and outbox adapter test (1) passed. Full suite remains blocked by the worker remote-AG-UI pending-response polling test timing out after 5000 ms. PostgreSQL-gated tests remain skipped without a disposable test database.
|
||||
- `pnpm build --concurrency=1`: passed with a longer command allowance after the first attempt timed out during build tracing. This is a local build, not a Docker image rebuild or deployment.
|
||||
- `git diff --check`: passed.
|
||||
- Security review of this slice: ticket origin is pinned to server configuration; forwarded-header spoofing, missing/malformed origins and workspace denial are covered. Redaction remains workspace/channel/message/create-event scoped. Production shared-password sign-in remains an operational security concern until explicitly resolved. No secrets were read or printed.
|
||||
|
||||
## Acceptance evidence (2026-10-29)
|
||||
|
||||
Two signed-in members (`me@randallstillwell.com` + `chat-acceptance-b@stillwell.local`) against workspace `randall-stillwell`:
|
||||
|
||||
| Gate | Result |
|
||||
|------|--------|
|
||||
| Secret shared web/collab | Pass |
|
||||
| Compose validate + ports | Pass |
|
||||
| Public WSS reject without ticket | Pass (connection fails) |
|
||||
| Dual WS subscribe via `wss://chat.stillwell.cloud/chat` | Pass |
|
||||
| Send + Redis invalidate hint | Pass |
|
||||
| Reply | Pass |
|
||||
| Reconnect catch-up | Pass |
|
||||
| Unread mark + summary | Pass |
|
||||
| Edit / delete + mutation catch-up | Pass (after redact fix) |
|
||||
| Membership revocation (hint + history denied) | Pass |
|
||||
|
|
@ -34,7 +34,7 @@ External: Postgres/Redis on **CT 102**, TLS on **CT 100**, OmniRoute on **CT 108
|
|||
| File | Use |
|
||||
|------|-----|
|
||||
| `docker/docker-compose.prod.yml` | Core stack |
|
||||
| `docker/docker-compose.prod.standalone.yml` | Publish `:3000` / `:1234` for CT 100 Traefik |
|
||||
| `docker/docker-compose.prod.standalone.yml` | Publish `:3000` / `:1234` / `:1235` for CT 100 Traefik |
|
||||
| `scripts/build-prod.sh` | Serialized image build |
|
||||
| `scripts/deploy-prod.sh` | Build + up + health wait |
|
||||
|
||||
|
|
@ -65,6 +65,9 @@ docker compose -f docker/docker-compose.prod.yml \
|
|||
| `AUTH_AUTHENTIK_*` | | Authentik OIDC app `echodo` |
|
||||
| `COLLAB_SERVER_URL` | `wss://collab.echodo.stillwell.cloud` | Server-side |
|
||||
| `NEXT_PUBLIC_COLLAB_SERVER_URL` | `wss://collab.echodo.stillwell.cloud` | **Build arg** — requires image rebuild |
|
||||
| `NEXT_PUBLIC_CHAT_SERVER_URL` | `wss://chat.stillwell.cloud` | **Build arg** — requires web image rebuild; provision DNS/TLS first |
|
||||
| `CHAT_TICKET_SECRET` | Generate with `openssl rand -base64 32` | Same runtime secret for web and collab; at least 32 characters; never a build arg |
|
||||
| `CHAT_PORT` | `1235` | Second listener inside collab; cannot equal Hocuspocus port `1234` |
|
||||
| `OMNIROUTE_BASE_URL` | `https://omniroute.stillwell.cloud/v1` | Web + worker |
|
||||
| `OMNIROUTE_API_KEY` | (your key) | + register in Settings → AI → Providers |
|
||||
| `FORGEJO_TOKEN` / `GITHUB_TOKEN` | PAT values | Referenced by name in Connected repos settings |
|
||||
|
|
@ -74,6 +77,33 @@ Changing any `NEXT_PUBLIC_*` value requires `bash scripts/deploy-prod.sh build`,
|
|||
|
||||
---
|
||||
|
||||
## Workspace Chat integration gate
|
||||
|
||||
Chat runs inside the existing `collab` service on a **separate listener** from Hocuspocus. The worker publishes metadata-only Redis hints; Postgres history and mutation catch-up remain authoritative. Team Chat does not invoke AI agents, and object contexts are not enabled by this deployment.
|
||||
|
||||
1. Set the Chat variables above in `/opt/submatter/.env`. Keep the signing secret server-side; Compose requires a nonempty value, and the application requires at least 32 characters. A missing/short secret disables the listener and makes the dual-listener health check fail.
|
||||
2. Provision `chat.stillwell.cloud` DNS and TLS on CT 100 (canonical browser endpoint). The proxy example also retains `chat.echodo.stillwell.cloud`; a single-level wildcard certificate does not cover that nested hostname. Apply the `echodo-chat` router/service from [`traefik-ct100.example.yml`](../proxmox/ct116/traefik-ct100.example.yml). Traefik supports WebSocket upgrades without a separate service process. If `CHAT_PORT` changes, update the proxy backend port too. Restrict host backend ports to the trusted proxy network.
|
||||
3. Before rolling out, verify the database target is CT 102 **`echodo`** and inspect `drizzle.__drizzle_migrations`. The operator reports 24 ledger entries through `0023`, verified Chat schema, and ownership of all `chat_*` tables transferred to `echodo`. Do not reapply these migrations; inspect the ledger for any future pending entries. Compare the ledger with `packages/database/migrations/meta/_journal.json`; apply only pending journaled migrations using `pnpm --filter @tasks/database db:migrate` in a checkout with the real `DATABASE_URL` exported securely. Do not print the URL, use `db:push`, or replay historical migrations manually. Take a backup first.
|
||||
4. Verify `0022` added `chat_messages.send_intent_fingerprint` and positive `revision`; verify `0023` added `chat_message_mutations`, its tenant/channel/message foreign keys and indexes, and `chat_channels.next_mutation_sequence`. Do not infer applied schema from TypeScript files. The historical `0017` correction is already local; do not replay it on an initialized database.
|
||||
5. Validate Compose on the deployment host without printing resolved secrets:
|
||||
|
||||
```bash
|
||||
docker compose --project-directory /opt/submatter --env-file /opt/submatter/.env \
|
||||
-f docker/docker-compose.prod.yml -f docker/docker-compose.prod.standalone.yml config --quiet
|
||||
bash scripts/deploy-prod.sh build
|
||||
```
|
||||
|
||||
6. Confirm both collab listeners and worker health. The TCP health check verifies listener liveness only, **not** Redis delivery, database authorization, or end-to-end readiness. An ordinary HTTP request to the Chat listener returns 404; use an authenticated browser WebSocket session for acceptance.
|
||||
7. Test two signed-in workspace members: send/reply/edit/delete, unread acknowledgement, reconnect catch-up, offline edits/deletes, and duplicate retries. Revoke private/direct membership while connected and verify subsequent hints/history are denied and cached content is cleared; admins must not bypass membership. Verify another workspace cannot obtain history or subscribe. Restart collab/worker and test Redis outage recovery without message loss.
|
||||
|
||||
Run PostgreSQL integration tests only against a disposable test database, not production. DNS, TLS, migrations, Redis/WebSocket delivery, and two-browser acceptance are independent release gates; passing unit tests does not satisfy them. See [Current Progress](Current%20Progress.md) for the current evidence and blockers.
|
||||
|
||||
### Post-go-live reconciliation
|
||||
|
||||
The operator reports Chat live with 14/14 acceptance checks; see [Current Progress](Current%20Progress.md) for provenance and scope. Rebuild web on the next deploy to replace hot-patched image chunks with source-built code. The reconciled ticket route uses `AUTH_URL` or `NEXTAUTH_URL` as its trusted public origin; set it to the HTTPS **web app** origin, not the WSS endpoint. It ignores forwarded headers for this decision and fails closed without valid production configuration. Restrict direct backend access to the proxy network regardless.
|
||||
|
||||
`AUTH_DEV_PASSWORD` was enabled for acceptance. This shared password can authenticate existing email identities; it is not individual-user password authentication. Confirm SSO works, then remove it and recreate web if it was temporary. Do not leave this enabled solely because acceptance needed it. Re-run ticket origin/authentication and live Chat acceptance after the source-built deployment.
|
||||
|
||||
## Auto-deploy on push
|
||||
|
||||
**CI** (every PR): `.github/workflows/ci.yml` — lint, type-check, test on GitHub-hosted or PR runner.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"dev": "turbo dev",
|
||||
"lint": "turbo lint",
|
||||
"type-check": "turbo type-check",
|
||||
"test": "turbo test",
|
||||
"test": "node --test scripts/chat-deployment.test.mjs && turbo test",
|
||||
"db:generate": "turbo db:generate --filter=@tasks/database",
|
||||
"db:migrate": "turbo db:migrate --filter=@tasks/database",
|
||||
"db:push": "turbo db:push --filter=@tasks/database",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,3 @@
|
|||
CREATE TABLE "convoy_operator_messages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"convoy_id" uuid NOT NULL,
|
||||
"brief_id" uuid,
|
||||
"agent_run_id" uuid,
|
||||
"delivery" varchar(20) NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"author_user_id" uuid NOT NULL,
|
||||
"delivered_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "completion_artifacts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"workspace_id" uuid NOT NULL,
|
||||
|
|
@ -21,15 +8,9 @@ CREATE TABLE "completion_artifacts" (
|
|||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_convoy_id_objects_id_fk" FOREIGN KEY ("convoy_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_brief_id_objects_id_fk" FOREIGN KEY ("brief_id") REFERENCES "public"."objects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_agent_run_id_agent_runs_id_fk" FOREIGN KEY ("agent_run_id") REFERENCES "public"."agent_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_author_user_id_users_id_fk" FOREIGN KEY ("author_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "completion_artifacts" ADD CONSTRAINT "completion_artifacts_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "completion_artifacts" ADD CONSTRAINT "completion_artifacts_convoy_id_objects_id_fk" FOREIGN KEY ("convoy_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "completion_artifacts" ADD CONSTRAINT "completion_artifacts_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "convoy_operator_messages_workspace_id_convoy_id_delivered_at_idx" ON "convoy_operator_messages" USING btree ("workspace_id","convoy_id","delivered_at");--> statement-breakpoint
|
||||
CREATE INDEX "completion_artifacts_workspace_id_idx" ON "completion_artifacts" USING btree ("workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX "completion_artifacts_convoy_id_idx" ON "completion_artifacts" USING btree ("convoy_id");--> statement-breakpoint
|
||||
CREATE INDEX "completion_artifacts_created_at_idx" ON "completion_artifacts" USING btree ("created_at");
|
||||
26
packages/database/migrations/0018_early_risque.sql
Normal file
26
packages/database/migrations/0018_early_risque.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
CREATE TABLE "budget_caps" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"cap_usd" numeric(12, 2) NOT NULL,
|
||||
"period" varchar(20) DEFAULT 'monthly' NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "spend_ledger" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"model_alias" varchar(120) NOT NULL,
|
||||
"provider_type" varchar(40) NOT NULL,
|
||||
"model_tier" varchar(20),
|
||||
"estimated_cost_usd" numeric(12, 6) NOT NULL,
|
||||
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "budget_caps" ADD CONSTRAINT "budget_caps_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "spend_ledger" ADD CONSTRAINT "spend_ledger_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "budget_caps_workspace_id_idx" ON "budget_caps" USING btree ("workspace_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "budget_caps_workspace_id_period_unique" ON "budget_caps" USING btree ("workspace_id","period");--> statement-breakpoint
|
||||
CREATE INDEX "spend_ledger_workspace_id_occurred_at_idx" ON "spend_ledger" USING btree ("workspace_id","occurred_at" DESC NULLS LAST);--> statement-breakpoint
|
||||
CREATE INDEX "spend_ledger_model_alias_occurred_at_idx" ON "spend_ledger" USING btree ("model_alias","occurred_at" DESC NULLS LAST);
|
||||
109
packages/database/migrations/0019_petite_monster_badoon.sql
Normal file
109
packages/database/migrations/0019_petite_monster_badoon.sql
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
CREATE TABLE "chat_channel_members" (
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"channel_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "chat_channel_members_workspace_id_channel_id_user_id_pk" PRIMARY KEY("workspace_id","channel_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "chat_channels" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"kind" varchar(16) NOT NULL,
|
||||
"name" varchar(200),
|
||||
"owner_user_id" uuid NOT NULL,
|
||||
"direct_key" text,
|
||||
"next_sequence" bigint DEFAULT 1 NOT NULL,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "chat_channels_workspace_id_id_unique" UNIQUE("workspace_id","id"),
|
||||
CONSTRAINT "chat_channels_kind_check" CHECK ("chat_channels"."kind" in ('public', 'private', 'direct')),
|
||||
CONSTRAINT "chat_channels_direct_key_check" CHECK (("chat_channels"."kind" = 'direct' and "chat_channels"."direct_key" is not null and "chat_channels"."name" is null) or ("chat_channels"."kind" <> 'direct' and "chat_channels"."direct_key" is null)),
|
||||
CONSTRAINT "chat_channels_next_sequence_check" CHECK ("chat_channels"."next_sequence" > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "chat_message_mentions" (
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"message_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
CONSTRAINT "chat_message_mentions_workspace_id_message_id_user_id_pk" PRIMARY KEY("workspace_id","message_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "chat_messages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"channel_id" uuid NOT NULL,
|
||||
"sequence" bigint NOT NULL,
|
||||
"parent_message_id" uuid,
|
||||
"author_type" varchar(16) NOT NULL,
|
||||
"author_user_id" uuid,
|
||||
"author_agent_id" uuid,
|
||||
"body" text NOT NULL,
|
||||
"status" varchar(16) DEFAULT 'sent' NOT NULL,
|
||||
"client_idempotency_key" varchar(200) NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"edited_at" timestamp with time zone,
|
||||
"deleted_at" timestamp with time zone,
|
||||
CONSTRAINT "chat_messages_workspace_id_id_unique" UNIQUE("workspace_id","id"),
|
||||
CONSTRAINT "chat_messages_workspace_channel_id_unique" UNIQUE("workspace_id","channel_id","id"),
|
||||
CONSTRAINT "chat_messages_workspace_channel_sequence_unique" UNIQUE("workspace_id","channel_id","sequence"),
|
||||
CONSTRAINT "chat_messages_workspace_channel_client_key_unique" UNIQUE("workspace_id","channel_id","client_idempotency_key"),
|
||||
CONSTRAINT "chat_messages_author_check" CHECK (("chat_messages"."author_type" = 'human' and "chat_messages"."author_user_id" is not null and "chat_messages"."author_agent_id" is null) or ("chat_messages"."author_type" = 'agent' and "chat_messages"."author_user_id" is null and "chat_messages"."author_agent_id" is not null) or ("chat_messages"."author_type" = 'system' and "chat_messages"."author_user_id" is null and "chat_messages"."author_agent_id" is null)),
|
||||
CONSTRAINT "chat_messages_status_check" CHECK ("chat_messages"."status" in ('sent', 'edited', 'deleted')),
|
||||
CONSTRAINT "chat_messages_sequence_check" CHECK ("chat_messages"."sequence" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "chat_outbox" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"message_id" uuid NOT NULL,
|
||||
"event_version" integer DEFAULT 1 NOT NULL,
|
||||
"event_type" varchar(100) NOT NULL,
|
||||
"dedupe_key" varchar(250) NOT NULL,
|
||||
"payload" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"published_at" timestamp with time zone,
|
||||
"attempts" integer DEFAULT 0 NOT NULL,
|
||||
"last_error" text,
|
||||
CONSTRAINT "chat_outbox_workspace_id_dedupe_key_unique" UNIQUE("workspace_id","dedupe_key"),
|
||||
CONSTRAINT "chat_outbox_event_version_check" CHECK ("chat_outbox"."event_version" > 0),
|
||||
CONSTRAINT "chat_outbox_attempts_check" CHECK ("chat_outbox"."attempts" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "chat_read_cursors" (
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"channel_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"sequence" bigint DEFAULT 0 NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "chat_read_cursors_workspace_id_channel_id_user_id_pk" PRIMARY KEY("workspace_id","channel_id","user_id"),
|
||||
CONSTRAINT "chat_read_cursors_sequence_check" CHECK ("chat_read_cursors"."sequence" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "chat_channel_members" ADD CONSTRAINT "chat_channel_members_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_channel_members" ADD CONSTRAINT "chat_channel_members_channel_fk" FOREIGN KEY ("workspace_id","channel_id") REFERENCES "public"."chat_channels"("workspace_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_channel_members" ADD CONSTRAINT "chat_channel_members_workspace_member_fk" FOREIGN KEY ("workspace_id","user_id") REFERENCES "public"."workspace_members"("workspace_id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_channels" ADD CONSTRAINT "chat_channels_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_channels" ADD CONSTRAINT "chat_channels_owner_member_fk" FOREIGN KEY ("workspace_id","owner_user_id") REFERENCES "public"."workspace_members"("workspace_id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_message_mentions" ADD CONSTRAINT "chat_message_mentions_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_message_mentions" ADD CONSTRAINT "chat_message_mentions_message_fk" FOREIGN KEY ("workspace_id","message_id") REFERENCES "public"."chat_messages"("workspace_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_message_mentions" ADD CONSTRAINT "chat_message_mentions_workspace_member_fk" FOREIGN KEY ("workspace_id","user_id") REFERENCES "public"."workspace_members"("workspace_id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_channel_fk" FOREIGN KEY ("workspace_id","channel_id") REFERENCES "public"."chat_channels"("workspace_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_parent_fk" FOREIGN KEY ("workspace_id","channel_id","parent_message_id") REFERENCES "public"."chat_messages"("workspace_id","channel_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_human_author_member_fk" FOREIGN KEY ("workspace_id","author_user_id") REFERENCES "public"."workspace_members"("workspace_id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_outbox" ADD CONSTRAINT "chat_outbox_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_outbox" ADD CONSTRAINT "chat_outbox_message_fk" FOREIGN KEY ("workspace_id","message_id") REFERENCES "public"."chat_messages"("workspace_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_read_cursors" ADD CONSTRAINT "chat_read_cursors_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_read_cursors" ADD CONSTRAINT "chat_read_cursors_channel_fk" FOREIGN KEY ("workspace_id","channel_id") REFERENCES "public"."chat_channels"("workspace_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_read_cursors" ADD CONSTRAINT "chat_read_cursors_workspace_member_fk" FOREIGN KEY ("workspace_id","user_id") REFERENCES "public"."workspace_members"("workspace_id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "chat_channel_members_workspace_id_idx" ON "chat_channel_members" USING btree ("workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX "chat_channel_members_workspace_id_user_id_idx" ON "chat_channel_members" USING btree ("workspace_id","user_id");--> statement-breakpoint
|
||||
CREATE INDEX "chat_channels_workspace_id_idx" ON "chat_channels" USING btree ("workspace_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "chat_channels_workspace_id_direct_key_unique" ON "chat_channels" USING btree ("workspace_id","direct_key") WHERE "chat_channels"."direct_key" is not null;--> statement-breakpoint
|
||||
CREATE INDEX "chat_message_mentions_workspace_id_idx" ON "chat_message_mentions" USING btree ("workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX "chat_messages_workspace_id_idx" ON "chat_messages" USING btree ("workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX "chat_outbox_workspace_id_idx" ON "chat_outbox" USING btree ("workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX "chat_outbox_unpublished_idx" ON "chat_outbox" USING btree ("created_at") WHERE "chat_outbox"."published_at" is null;--> statement-breakpoint
|
||||
CREATE INDEX "chat_read_cursors_workspace_id_idx" ON "chat_read_cursors" USING btree ("workspace_id");
|
||||
19
packages/database/migrations/0020_demonic_hammerhead.sql
Normal file
19
packages/database/migrations/0020_demonic_hammerhead.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
CREATE TABLE "chat_mutation_receipts" (
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"actor_user_id" uuid NOT NULL,
|
||||
"client_mutation_id" uuid NOT NULL,
|
||||
"operation" varchar(50) NOT NULL,
|
||||
"fingerprint" text NOT NULL,
|
||||
"result" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "chat_mutation_receipts_workspace_id_actor_user_id_client_mutation_id_pk" PRIMARY KEY("workspace_id","actor_user_id","client_mutation_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "chat_outbox" ALTER COLUMN "message_id" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "chat_outbox" ADD COLUMN "channel_id" uuid;--> statement-breakpoint
|
||||
UPDATE "chat_outbox" AS o SET "channel_id" = m."channel_id" FROM "chat_messages" AS m WHERE o."workspace_id" = m."workspace_id" AND o."message_id" = m."id";--> statement-breakpoint
|
||||
ALTER TABLE "chat_outbox" ALTER COLUMN "channel_id" SET NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "chat_mutation_receipts" ADD CONSTRAINT "chat_mutation_receipts_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_mutation_receipts" ADD CONSTRAINT "chat_mutation_receipts_workspace_member_fk" FOREIGN KEY ("workspace_id","actor_user_id") REFERENCES "public"."workspace_members"("workspace_id","user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "chat_mutation_receipts_workspace_id_idx" ON "chat_mutation_receipts" USING btree ("workspace_id");--> statement-breakpoint
|
||||
ALTER TABLE "chat_outbox" ADD CONSTRAINT "chat_outbox_channel_fk" FOREIGN KEY ("workspace_id","channel_id") REFERENCES "public"."chat_channels"("workspace_id","id") ON DELETE cascade ON UPDATE no action;
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
CREATE FUNCTION public.enforce_chat_direct_channel_invariant() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
DECLARE canonical_key text;
|
||||
DECLARE member_count integer;
|
||||
BEGIN
|
||||
IF OLD.kind = 'direct' AND (NEW.kind IS DISTINCT FROM OLD.kind OR NEW.direct_key IS DISTINCT FROM OLD.direct_key OR NEW.workspace_id IS DISTINCT FROM OLD.workspace_id) THEN
|
||||
RAISE EXCEPTION 'direct channel identity is immutable' USING ERRCODE = '23514', CONSTRAINT = 'chat_direct_channel_identity_immutable';
|
||||
END IF;
|
||||
IF OLD.kind <> 'direct' AND NEW.kind = 'direct' THEN
|
||||
IF NEW.workspace_id IS DISTINCT FROM OLD.workspace_id OR NEW.name IS NOT NULL OR NEW.direct_key IS NULL THEN
|
||||
RAISE EXCEPTION 'invalid direct channel transition' USING ERRCODE = '23514', CONSTRAINT = 'chat_direct_channel_transition_valid';
|
||||
END IF;
|
||||
SELECT md5(string_agg(m.user_id::text, ':' ORDER BY m.user_id::text)), count(*)
|
||||
INTO canonical_key, member_count
|
||||
FROM public.chat_channel_members m
|
||||
WHERE m.workspace_id = NEW.workspace_id AND m.channel_id = NEW.id;
|
||||
IF member_count < 2 OR canonical_key IS DISTINCT FROM NEW.direct_key OR NOT EXISTS (
|
||||
SELECT 1 FROM public.chat_channel_members m WHERE m.workspace_id = NEW.workspace_id AND m.channel_id = NEW.id AND m.user_id = NEW.owner_user_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'direct channel participants do not match canonical key' USING ERRCODE = '23514', CONSTRAINT = 'chat_direct_channel_participants_valid';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
--> statement-breakpoint
|
||||
REVOKE ALL ON FUNCTION public.enforce_chat_direct_channel_invariant() FROM PUBLIC;
|
||||
--> statement-breakpoint
|
||||
CREATE TRIGGER chat_direct_channel_invariant BEFORE UPDATE ON public.chat_channels FOR EACH ROW EXECUTE FUNCTION public.enforce_chat_direct_channel_invariant();
|
||||
--> statement-breakpoint
|
||||
CREATE FUNCTION public.enforce_chat_direct_membership_immutability() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM public.chat_channels c WHERE c.workspace_id = COALESCE(OLD.workspace_id, NEW.workspace_id) AND c.id = COALESCE(OLD.channel_id, NEW.channel_id) AND c.kind = 'direct')
|
||||
OR (TG_OP = 'UPDATE' AND EXISTS (SELECT 1 FROM public.chat_channels c WHERE c.workspace_id = NEW.workspace_id AND c.id = NEW.channel_id AND c.kind = 'direct')) THEN
|
||||
RAISE EXCEPTION 'direct channel membership is immutable' USING ERRCODE = '23514', CONSTRAINT = 'chat_direct_membership_immutable';
|
||||
END IF;
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$;
|
||||
--> statement-breakpoint
|
||||
REVOKE ALL ON FUNCTION public.enforce_chat_direct_membership_immutability() FROM PUBLIC;
|
||||
--> statement-breakpoint
|
||||
CREATE TRIGGER chat_direct_membership_immutability BEFORE INSERT OR UPDATE OR DELETE ON public.chat_channel_members FOR EACH ROW EXECUTE FUNCTION public.enforce_chat_direct_membership_immutability();
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE "chat_messages" ADD COLUMN "send_intent_fingerprint" text;--> statement-breakpoint
|
||||
ALTER TABLE "chat_messages" ADD COLUMN "revision" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_revision_check" CHECK ("chat_messages"."revision" > 0);
|
||||
18
packages/database/migrations/0023_thick_shinko_yamashiro.sql
Normal file
18
packages/database/migrations/0023_thick_shinko_yamashiro.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
CREATE TABLE "chat_message_mutations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"channel_id" uuid NOT NULL,
|
||||
"message_id" uuid NOT NULL,
|
||||
"mutation_sequence" bigint NOT NULL,
|
||||
"revision" integer NOT NULL,
|
||||
"kind" varchar(16) NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "chat_message_mutations_channel_sequence_unique" UNIQUE("workspace_id","channel_id","mutation_sequence"),
|
||||
CONSTRAINT "chat_message_mutations_revision_check" CHECK ("chat_message_mutations"."revision" > 1)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "chat_channels" ADD COLUMN "next_mutation_sequence" bigint DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "chat_message_mutations" ADD CONSTRAINT "chat_message_mutations_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_message_mutations" ADD CONSTRAINT "chat_message_mutations_channel_fk" FOREIGN KEY ("workspace_id","channel_id") REFERENCES "public"."chat_channels"("workspace_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_message_mutations" ADD CONSTRAINT "chat_message_mutations_message_fk" FOREIGN KEY ("workspace_id","message_id") REFERENCES "public"."chat_messages"("workspace_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "chat_message_mutations_workspace_id_idx" ON "chat_message_mutations" USING btree ("workspace_id");
|
||||
4782
packages/database/migrations/meta/0018_snapshot.json
Normal file
4782
packages/database/migrations/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5683
packages/database/migrations/meta/0019_snapshot.json
Normal file
5683
packages/database/migrations/meta/0019_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5814
packages/database/migrations/meta/0020_snapshot.json
Normal file
5814
packages/database/migrations/meta/0020_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5831
packages/database/migrations/meta/0022_snapshot.json
Normal file
5831
packages/database/migrations/meta/0022_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5976
packages/database/migrations/meta/0023_snapshot.json
Normal file
5976
packages/database/migrations/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -127,6 +127,48 @@
|
|||
"when": 1787698170280,
|
||||
"tag": "0017_lively_squadron_supreme",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1792737772691,
|
||||
"tag": "0018_early_risque",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1792763738904,
|
||||
"tag": "0019_petite_monster_badoon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1792766022303,
|
||||
"tag": "0020_demonic_hammerhead",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1792766022304,
|
||||
"tag": "0021_chat_direct_membership_immutability",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1793053728379,
|
||||
"tag": "0022_skinny_masked_marvel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1793053797739,
|
||||
"tag": "0023_thick_shinko_yamashiro",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
".": "./src/index.ts",
|
||||
"./schema": "./src/schema/index.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./chat": "./src/chat/index.ts",
|
||||
"./markdown-backlog": "./src/markdown-backlog/index.ts",
|
||||
"./model-routing": "./src/model-routing/index.ts"
|
||||
},
|
||||
|
|
|
|||
206
packages/database/src/chat/channel-drizzle-adapter.test.ts
Normal file
206
packages/database/src/chat/channel-drizzle-adapter.test.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createChannelTransactionRunner } from "./channel-drizzle-adapter";
|
||||
|
||||
type ChannelDatabase = NonNullable<
|
||||
Parameters<typeof createChannelTransactionRunner>[0]
|
||||
>;
|
||||
type DatabaseTransaction = Parameters<
|
||||
Parameters<ChannelDatabase["transaction"]>[0]
|
||||
>[0];
|
||||
type TransactionCallback = (tx: DatabaseTransaction) => Promise<unknown>;
|
||||
|
||||
function fakeDatabase(
|
||||
transaction: (callback: TransactionCallback) => Promise<unknown>,
|
||||
): ChannelDatabase {
|
||||
return { transaction } as unknown as ChannelDatabase;
|
||||
}
|
||||
|
||||
const fakeTx = {} as DatabaseTransaction;
|
||||
|
||||
function pgError(code: string): Error & { code: string } {
|
||||
return Object.assign(new Error(`PostgreSQL ${code}`), { code });
|
||||
}
|
||||
|
||||
describe("createChannelTransactionRunner", () => {
|
||||
it("projects only ChannelRecord fields from database rows for strict receipts", async () => {
|
||||
const expected = {
|
||||
id: "10000000-0000-4000-8000-000000000001",
|
||||
workspaceId: "20000000-0000-4000-8000-000000000001",
|
||||
kind: "public",
|
||||
name: "general",
|
||||
ownerUserId: "30000000-0000-4000-8000-000000000001",
|
||||
directKey: null,
|
||||
archivedAt: null,
|
||||
};
|
||||
const records = [
|
||||
{
|
||||
...expected,
|
||||
nextSequence: 42n,
|
||||
nextMutationSequence: 9n,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
const tx = {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
for: async () => records,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
} as unknown as DatabaseTransaction;
|
||||
const result = await createChannelTransactionRunner(
|
||||
fakeDatabase(async (run) => run(tx)),
|
||||
)((adapter) => adapter.lockChannel(expected.workspaceId, expected.id));
|
||||
expect(result).toEqual(expected);
|
||||
expect(() => JSON.stringify(result)).not.toThrow();
|
||||
});
|
||||
it.each(["40001", "40P01"])(
|
||||
"replays the callback in a fresh transaction after %s",
|
||||
async (code) => {
|
||||
const transaction = vi.fn(async (callback: TransactionCallback) =>
|
||||
callback(fakeTx),
|
||||
);
|
||||
const run = vi.fn(async () => {
|
||||
if (run.mock.calls.length === 1) throw pgError(code);
|
||||
return "committed";
|
||||
});
|
||||
|
||||
await expect(
|
||||
createChannelTransactionRunner(fakeDatabase(transaction))(run),
|
||||
).resolves.toBe("committed");
|
||||
expect(transaction).toHaveBeenCalledTimes(2);
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
|
||||
it("replays the callback when a wrapped deadlock rejects after the callback returns", async () => {
|
||||
const transaction = vi.fn(async (callback: TransactionCallback) => {
|
||||
const result = await callback(fakeTx);
|
||||
if (transaction.mock.calls.length === 1) {
|
||||
throw new Error("transaction failed", { cause: pgError("40P01") });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
const run = vi.fn(async () => "committed");
|
||||
|
||||
await expect(
|
||||
createChannelTransactionRunner(fakeDatabase(transaction))(run),
|
||||
).resolves.toBe("committed");
|
||||
expect(transaction).toHaveBeenCalledTimes(2);
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rethrows the original wrapped serialization error after three attempts", async () => {
|
||||
const original = new Error("transaction failed", {
|
||||
cause: new Error("driver failed", { cause: pgError("40001") }),
|
||||
});
|
||||
const transaction = vi.fn(async (callback: TransactionCallback) => {
|
||||
await callback(fakeTx);
|
||||
throw original;
|
||||
});
|
||||
const run = vi.fn(async () => "not committed");
|
||||
|
||||
await expect(
|
||||
createChannelTransactionRunner(fakeDatabase(transaction))(run),
|
||||
).rejects.toBe(original);
|
||||
expect(transaction).toHaveBeenCalledTimes(3);
|
||||
expect(run).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("does not retry unrelated failures", async () => {
|
||||
const original = new Error("invalid input", { cause: pgError("23505") });
|
||||
const transaction = vi.fn(async (callback: TransactionCallback) =>
|
||||
callback(fakeTx),
|
||||
);
|
||||
const run = vi.fn(async () => {
|
||||
throw original;
|
||||
});
|
||||
|
||||
await expect(
|
||||
createChannelTransactionRunner(fakeDatabase(transaction))(run),
|
||||
).rejects.toBe(original);
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("channel membership persistence", () => {
|
||||
it("does not mark a no-op insert as changed", async () => {
|
||||
const returning = vi.fn().mockResolvedValue([]);
|
||||
const tx = {
|
||||
insert: vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
onConflictDoNothing: vi.fn().mockReturnValue({ returning }),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
const database = {
|
||||
transaction: async (fn: (transaction: typeof tx) => Promise<boolean>) =>
|
||||
fn(tx),
|
||||
};
|
||||
await expect(
|
||||
createChannelTransactionRunner(
|
||||
database as unknown as Parameters<
|
||||
typeof createChannelTransactionRunner
|
||||
>[0],
|
||||
)((adapter) => adapter.addMember("workspace", "channel", "member")),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark a no-op delete as changed", async () => {
|
||||
const returning = vi.fn().mockResolvedValue([]);
|
||||
const tx = {
|
||||
delete: vi
|
||||
.fn()
|
||||
.mockReturnValue({ where: vi.fn().mockReturnValue({ returning }) }),
|
||||
};
|
||||
const database = {
|
||||
transaction: async (fn: (transaction: typeof tx) => Promise<boolean>) =>
|
||||
fn(tx),
|
||||
};
|
||||
await expect(
|
||||
createChannelTransactionRunner(
|
||||
database as unknown as Parameters<
|
||||
typeof createChannelTransactionRunner
|
||||
>[0],
|
||||
)((adapter) => adapter.removeMember("workspace", "channel", "member")),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("reports inserted members using returned rows rather than driver command metadata", async () => {
|
||||
const returning = vi.fn().mockResolvedValue([{ userId: "member" }]);
|
||||
const onConflictDoNothing = vi.fn().mockReturnValue({ returning });
|
||||
const values = vi.fn().mockReturnValue({ onConflictDoNothing });
|
||||
const tx = { insert: vi.fn().mockReturnValue({ values }) };
|
||||
const database = {
|
||||
transaction: async (fn: (transaction: typeof tx) => Promise<boolean>) =>
|
||||
fn(tx),
|
||||
};
|
||||
const changed = await createChannelTransactionRunner(
|
||||
database as unknown as Parameters<
|
||||
typeof createChannelTransactionRunner
|
||||
>[0],
|
||||
)((adapter) => adapter.addMember("workspace", "channel", "member"));
|
||||
expect(changed).toBe(true);
|
||||
expect(returning).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports deleted members using returned rows rather than driver command metadata", async () => {
|
||||
const returning = vi.fn().mockResolvedValue([{ userId: "member" }]);
|
||||
const where = vi.fn().mockReturnValue({ returning });
|
||||
const tx = { delete: vi.fn().mockReturnValue({ where }) };
|
||||
const database = {
|
||||
transaction: async (fn: (transaction: typeof tx) => Promise<boolean>) =>
|
||||
fn(tx),
|
||||
};
|
||||
const changed = await createChannelTransactionRunner(
|
||||
database as unknown as Parameters<
|
||||
typeof createChannelTransactionRunner
|
||||
>[0],
|
||||
)((adapter) => adapter.removeMember("workspace", "channel", "member"));
|
||||
expect(changed).toBe(true);
|
||||
expect(returning).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
348
packages/database/src/chat/channel-drizzle-adapter.ts
Normal file
348
packages/database/src/chat/channel-drizzle-adapter.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { chatChannelLifecycleEventV1Schema } from "@tasks/shared";
|
||||
import { db as defaultDb } from "../client";
|
||||
import {
|
||||
chatChannelMembers,
|
||||
chatChannels,
|
||||
chatMutationReceipts,
|
||||
chatOutbox,
|
||||
workspaceMembers,
|
||||
} from "../schema";
|
||||
import {
|
||||
ChannelPersistenceError,
|
||||
type ChannelRunner,
|
||||
type ChannelTransaction,
|
||||
} from "./manage-channels";
|
||||
type Tx = Parameters<Parameters<typeof defaultDb.transaction>[0]>[0];
|
||||
const row = (c: typeof chatChannels.$inferSelect) => ({
|
||||
id: c.id,
|
||||
workspaceId: c.workspaceId,
|
||||
kind: c.kind,
|
||||
name: c.name,
|
||||
ownerUserId: c.ownerUserId,
|
||||
directKey: c.directKey,
|
||||
archivedAt: c.archivedAt,
|
||||
});
|
||||
function translate(error: unknown): never {
|
||||
const record =
|
||||
typeof error === "object" && error
|
||||
? (error as { code?: string; constraint?: string })
|
||||
: {};
|
||||
if (
|
||||
record.constraint === "chat_direct_membership_immutable" ||
|
||||
record.constraint === "chat_direct_channel_identity_immutable"
|
||||
)
|
||||
throw new ChannelPersistenceError("READ_ONLY");
|
||||
if (
|
||||
record.constraint === "chat_direct_channel_transition_valid" ||
|
||||
record.constraint === "chat_direct_channel_participants_valid"
|
||||
)
|
||||
throw new ChannelPersistenceError("INVALID");
|
||||
if (
|
||||
record.constraint ===
|
||||
"chat_mutation_receipts_workspace_id_actor_user_id_client_mutation_id_pk" ||
|
||||
record.constraint === "chat_channels_workspace_id_direct_key_unique"
|
||||
)
|
||||
throw new ChannelPersistenceError("CONFLICT");
|
||||
if (
|
||||
record.constraint === "chat_channel_members_workspace_member_fk" ||
|
||||
record.constraint === "chat_channels_owner_member_fk"
|
||||
)
|
||||
throw new ChannelPersistenceError("NOT_FOUND");
|
||||
throw error;
|
||||
}
|
||||
function adapter(tx: Tx): ChannelTransaction {
|
||||
return {
|
||||
async loadActor(workspaceId, userId) {
|
||||
const [m] = await tx
|
||||
.select({
|
||||
userId: workspaceMembers.userId,
|
||||
role: workspaceMembers.role,
|
||||
})
|
||||
.from(workspaceMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMembers.workspaceId, workspaceId),
|
||||
eq(workspaceMembers.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return m ?? null;
|
||||
},
|
||||
async listVisible(workspaceId, actorUserId) {
|
||||
return (
|
||||
await tx
|
||||
.selectDistinct({ channel: chatChannels })
|
||||
.from(chatChannels)
|
||||
.leftJoin(
|
||||
chatChannelMembers,
|
||||
and(
|
||||
eq(chatChannelMembers.workspaceId, workspaceId),
|
||||
eq(chatChannelMembers.channelId, chatChannels.id),
|
||||
eq(chatChannelMembers.userId, actorUserId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatChannels.workspaceId, workspaceId),
|
||||
or(
|
||||
eq(chatChannels.kind, "public"),
|
||||
eq(chatChannelMembers.userId, actorUserId),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(chatChannels.createdAt))
|
||||
).map((x) => row(x.channel));
|
||||
},
|
||||
async lockChannel(workspaceId, channelId) {
|
||||
const [c] = await tx
|
||||
.select()
|
||||
.from(chatChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(chatChannels.workspaceId, workspaceId),
|
||||
eq(chatChannels.id, channelId),
|
||||
),
|
||||
)
|
||||
.for("update");
|
||||
return c ? row(c) : null;
|
||||
},
|
||||
async isChannelMember(workspaceId, channelId, userId) {
|
||||
const [m] = await tx
|
||||
.select({ userId: chatChannelMembers.userId })
|
||||
.from(chatChannelMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(chatChannelMembers.workspaceId, workspaceId),
|
||||
eq(chatChannelMembers.channelId, channelId),
|
||||
eq(chatChannelMembers.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return Boolean(m);
|
||||
},
|
||||
async areWorkspaceMembers(workspaceId, userIds) {
|
||||
if (!userIds.length) return false;
|
||||
const found = await tx
|
||||
.select({ userId: workspaceMembers.userId })
|
||||
.from(workspaceMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMembers.workspaceId, workspaceId),
|
||||
inArray(workspaceMembers.userId, userIds),
|
||||
),
|
||||
);
|
||||
return found.length === new Set(userIds).size;
|
||||
},
|
||||
async insertChannel(input) {
|
||||
try {
|
||||
const [c] = await tx.insert(chatChannels).values(input).returning();
|
||||
return row(c!);
|
||||
} catch (e) {
|
||||
translate(e);
|
||||
}
|
||||
},
|
||||
async renameActiveChannel(workspaceId, channelId, name) {
|
||||
const [c] = await tx
|
||||
.update(chatChannels)
|
||||
.set({ name, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatChannels.workspaceId, workspaceId),
|
||||
eq(chatChannels.id, channelId),
|
||||
isNull(chatChannels.archivedAt),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return c ? row(c) : null;
|
||||
},
|
||||
async archiveActiveChannel(workspaceId, channelId) {
|
||||
const [c] = await tx
|
||||
.update(chatChannels)
|
||||
.set({ archivedAt: new Date(), updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatChannels.workspaceId, workspaceId),
|
||||
eq(chatChannels.id, channelId),
|
||||
isNull(chatChannels.archivedAt),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return c ? row(c) : null;
|
||||
},
|
||||
async addMember(workspaceId, channelId, userId) {
|
||||
try {
|
||||
const inserted = await tx
|
||||
.insert(chatChannelMembers)
|
||||
.values({ workspaceId, channelId, userId })
|
||||
.onConflictDoNothing()
|
||||
.returning({ userId: chatChannelMembers.userId });
|
||||
return inserted.length > 0;
|
||||
} catch (e) {
|
||||
translate(e);
|
||||
}
|
||||
},
|
||||
async removeMember(workspaceId, channelId, userId) {
|
||||
const deleted = await tx
|
||||
.delete(chatChannelMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(chatChannelMembers.workspaceId, workspaceId),
|
||||
eq(chatChannelMembers.channelId, channelId),
|
||||
eq(chatChannelMembers.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning({ userId: chatChannelMembers.userId });
|
||||
return deleted.length > 0;
|
||||
},
|
||||
async lockDirect(workspaceId, directKey) {
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(${`${workspaceId}:${directKey}`},0))`,
|
||||
);
|
||||
},
|
||||
async findDirect(workspaceId, directKey) {
|
||||
const [c] = await tx
|
||||
.select()
|
||||
.from(chatChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(chatChannels.workspaceId, workspaceId),
|
||||
eq(chatChannels.directKey, directKey),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return c ? row(c) : null;
|
||||
},
|
||||
async insertDirect(input) {
|
||||
try {
|
||||
const [c] = await tx
|
||||
.insert(chatChannels)
|
||||
.values({
|
||||
...input,
|
||||
kind: "private",
|
||||
name: "__direct_staging__",
|
||||
directKey: null,
|
||||
})
|
||||
.returning();
|
||||
return c ? row(c) : null;
|
||||
} catch (e) {
|
||||
translate(e);
|
||||
}
|
||||
},
|
||||
async addInitialDirectMembers(workspaceId, channelId, userIds) {
|
||||
await tx
|
||||
.insert(chatChannelMembers)
|
||||
.values(userIds.map((userId) => ({ workspaceId, channelId, userId })));
|
||||
},
|
||||
async finalizeDirect(workspaceId, channelId, directKey) {
|
||||
const [channel] = await tx
|
||||
.update(chatChannels)
|
||||
.set({ kind: "direct", name: null, directKey, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatChannels.workspaceId, workspaceId),
|
||||
eq(chatChannels.id, channelId),
|
||||
eq(chatChannels.kind, "private"),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (!channel) throw new ChannelPersistenceError("CONFLICT");
|
||||
return row(channel);
|
||||
},
|
||||
async listMemberIds(workspaceId, channelId) {
|
||||
const rows = await tx
|
||||
.select({ userId: chatChannelMembers.userId })
|
||||
.from(chatChannelMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(chatChannelMembers.workspaceId, workspaceId),
|
||||
eq(chatChannelMembers.channelId, channelId),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(chatChannelMembers.userId));
|
||||
return rows.map((x) => x.userId);
|
||||
},
|
||||
async lockMutation(scope) {
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(${`${scope.workspaceId}:${scope.actorUserId}:${scope.clientMutationId}`}, 0))`,
|
||||
);
|
||||
},
|
||||
async loadReceipt(scope) {
|
||||
const [receipt] = await tx
|
||||
.select()
|
||||
.from(chatMutationReceipts)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMutationReceipts.workspaceId, scope.workspaceId),
|
||||
eq(chatMutationReceipts.actorUserId, scope.actorUserId),
|
||||
eq(chatMutationReceipts.clientMutationId, scope.clientMutationId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return receipt
|
||||
? {
|
||||
operation: receipt.operation,
|
||||
fingerprint: receipt.fingerprint,
|
||||
result: receipt.result,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
async saveReceipt(scope, operation, fingerprint, result) {
|
||||
try {
|
||||
await tx.insert(chatMutationReceipts).values({
|
||||
...scope,
|
||||
operation,
|
||||
fingerprint,
|
||||
result: result as Record<string, unknown>,
|
||||
});
|
||||
} catch (error) {
|
||||
translate(error);
|
||||
}
|
||||
},
|
||||
async emit(value) {
|
||||
const e = chatChannelLifecycleEventV1Schema.parse(value);
|
||||
const subject = "subjectUserId" in e ? e.subjectUserId : "channel";
|
||||
await tx.insert(chatOutbox).values({
|
||||
workspaceId: e.workspaceId,
|
||||
channelId: e.channelId,
|
||||
eventType: e.eventType,
|
||||
eventVersion: e.eventVersion,
|
||||
dedupeKey: `${e.eventType}:${e.channelId}:${subject}:${e.clientMutationId}`,
|
||||
payload: e,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
function isRetryableTransactionError(error: unknown): boolean {
|
||||
const seen = new Set<unknown>();
|
||||
let current = error;
|
||||
while (
|
||||
current !== null &&
|
||||
typeof current === "object" &&
|
||||
!seen.has(current)
|
||||
) {
|
||||
seen.add(current);
|
||||
if (
|
||||
"code" in current &&
|
||||
(current.code === "40001" || current.code === "40P01")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
current = "cause" in current ? current.cause : undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createChannelTransactionRunner(
|
||||
database: typeof defaultDb = defaultDb,
|
||||
): ChannelRunner {
|
||||
return async (fn) => {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
return await database.transaction((tx) => fn(adapter(tx)));
|
||||
} catch (error) {
|
||||
if (attempt >= 3 || !isRetryableTransactionError(error)) throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
139
packages/database/src/chat/drizzle-adapter.test.ts
Normal file
139
packages/database/src/chat/drizzle-adapter.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createChatTransactionRunner,
|
||||
type ChatDatabase,
|
||||
} from "./drizzle-adapter";
|
||||
|
||||
type DatabaseTransaction = Parameters<
|
||||
Parameters<ChatDatabase["transaction"]>[0]
|
||||
>[0];
|
||||
type TransactionCallback = (tx: DatabaseTransaction) => Promise<unknown>;
|
||||
|
||||
// These fakes exercise the transaction boundary, not a live PostgreSQL connection.
|
||||
function fakeDatabase(
|
||||
transaction: (callback: TransactionCallback) => Promise<unknown>,
|
||||
): ChatDatabase {
|
||||
return { transaction } as unknown as ChatDatabase;
|
||||
}
|
||||
|
||||
const fakeTx = {} as DatabaseTransaction;
|
||||
|
||||
function pgError(code: string): Error & { code: string } {
|
||||
return Object.assign(new Error(`PostgreSQL ${code}`), { code });
|
||||
}
|
||||
|
||||
describe("createChatTransactionRunner", () => {
|
||||
it.each(["40001", "40P01"])(
|
||||
"restarts the entire callback in a fresh transaction after %s",
|
||||
async (code) => {
|
||||
const transaction = vi.fn(async (callback: TransactionCallback) =>
|
||||
callback(fakeTx),
|
||||
);
|
||||
const run = vi.fn(async () => {
|
||||
if (run.mock.calls.length === 1) throw pgError(code);
|
||||
return "committed";
|
||||
});
|
||||
|
||||
await expect(
|
||||
createChatTransactionRunner(fakeDatabase(transaction)).transaction(run),
|
||||
).resolves.toBe("committed");
|
||||
expect(transaction).toHaveBeenCalledTimes(2);
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
|
||||
it("replays the callback when the transaction rejects after it returns", async () => {
|
||||
const transaction = vi.fn(async (callback: TransactionCallback) => {
|
||||
const result = await callback(fakeTx);
|
||||
if (transaction.mock.calls.length === 1) throw pgError("40001");
|
||||
return result;
|
||||
});
|
||||
const run = vi.fn(async () => "committed");
|
||||
|
||||
await expect(
|
||||
createChatTransactionRunner(fakeDatabase(transaction)).transaction(run),
|
||||
).resolves.toBe("committed");
|
||||
expect(transaction).toHaveBeenCalledTimes(2);
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries a serialization failure wrapped in a cause", async () => {
|
||||
const transaction = vi.fn(async (callback: TransactionCallback) =>
|
||||
callback(fakeTx),
|
||||
);
|
||||
const run = vi.fn(async () => {
|
||||
if (run.mock.calls.length === 1)
|
||||
throw new Error("query failed", { cause: pgError("40001") });
|
||||
return 42;
|
||||
});
|
||||
|
||||
await expect(
|
||||
createChatTransactionRunner(fakeDatabase(transaction)).transaction(run),
|
||||
).resolves.toBe(42);
|
||||
expect(transaction).toHaveBeenCalledTimes(2);
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("stops after three total attempts and rethrows the last error unchanged", async () => {
|
||||
const failures = [pgError("40001"), pgError("40P01"), pgError("40001")];
|
||||
const transaction = vi.fn(async (callback: TransactionCallback) =>
|
||||
callback(fakeTx),
|
||||
);
|
||||
const run = vi.fn(async () => {
|
||||
throw failures[run.mock.calls.length - 1];
|
||||
});
|
||||
|
||||
await expect(
|
||||
createChatTransactionRunner(fakeDatabase(transaction)).transaction(run),
|
||||
).rejects.toBe(failures[2]);
|
||||
expect(transaction).toHaveBeenCalledTimes(3);
|
||||
expect(run).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it.each([
|
||||
new Error("unrelated"),
|
||||
pgError("23505"),
|
||||
pgError("CHAT_FORBIDDEN"),
|
||||
])("does not retry unrelated or domain failures: %s", async (failure) => {
|
||||
const transaction = vi.fn(async (callback: TransactionCallback) =>
|
||||
callback(fakeTx),
|
||||
);
|
||||
const run = vi.fn(async () => {
|
||||
throw failure;
|
||||
});
|
||||
|
||||
await expect(
|
||||
createChatTransactionRunner(fakeDatabase(transaction)).transaction(run),
|
||||
).rejects.toBe(failure);
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("replays the whole callback after a rolled-back attempt, not just the failed operation", async () => {
|
||||
const committed: string[] = [];
|
||||
let attempts = 0;
|
||||
let staged: string[] = [];
|
||||
const transaction = vi.fn(async (callback: TransactionCallback) => {
|
||||
staged = [];
|
||||
attempts++;
|
||||
// A fake rollback model: failed attempts never append staged writes.
|
||||
const result = await callback(fakeTx);
|
||||
committed.push(...staged);
|
||||
return result;
|
||||
});
|
||||
const run = vi.fn(async () => {
|
||||
staged.push(`first-${attempts}`);
|
||||
if (attempts === 1) throw pgError("40P01");
|
||||
staged.push(`second-${attempts}`);
|
||||
return "done";
|
||||
});
|
||||
|
||||
await expect(
|
||||
createChatTransactionRunner(fakeDatabase(transaction)).transaction(run),
|
||||
).resolves.toBe("done");
|
||||
expect(committed).toEqual(["first-2", "second-2"]);
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(transaction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
245
packages/database/src/chat/drizzle-adapter.ts
Normal file
245
packages/database/src/chat/drizzle-adapter.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
|
||||
import { db as defaultDb } from "../client";
|
||||
import {
|
||||
chatChannelMembers,
|
||||
chatChannels,
|
||||
chatMessageMentions,
|
||||
chatMessages,
|
||||
chatOutbox,
|
||||
workspaceMembers,
|
||||
} from "../schema";
|
||||
import {
|
||||
sendChatMessage,
|
||||
type ChatPersistenceTransaction,
|
||||
type ChatTransactionRunner,
|
||||
} from "./send-message";
|
||||
|
||||
export type ChatDatabase = typeof defaultDb;
|
||||
type DatabaseTransaction = Parameters<
|
||||
Parameters<ChatDatabase["transaction"]>[0]
|
||||
>[0];
|
||||
|
||||
export function transactionAdapter(
|
||||
tx: DatabaseTransaction,
|
||||
): ChatPersistenceTransaction {
|
||||
return {
|
||||
async claimIdempotency(scope) {
|
||||
const lockKey = `${scope.workspaceId}:${scope.channelId}:${scope.clientIdempotencyKey}`;
|
||||
// A 64-bit hash collision only adds serialization; it cannot merge keys
|
||||
// because the following lookup still compares every original scope field.
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`,
|
||||
);
|
||||
const [message] = await tx
|
||||
.select()
|
||||
.from(chatMessages)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessages.workspaceId, scope.workspaceId),
|
||||
eq(chatMessages.channelId, scope.channelId),
|
||||
eq(chatMessages.clientIdempotencyKey, scope.clientIdempotencyKey),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return message
|
||||
? {
|
||||
id: message.id,
|
||||
workspaceId: message.workspaceId,
|
||||
channelId: message.channelId,
|
||||
sequence: message.sequence,
|
||||
body: message.body,
|
||||
parentMessageId: message.parentMessageId,
|
||||
authorUserId: message.authorUserId!,
|
||||
clientIdempotencyKey: message.clientIdempotencyKey,
|
||||
sendIntentFingerprint: message.sendIntentFingerprint,
|
||||
status: message.status,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
async loadMessageMentions(scope) {
|
||||
const rows = await tx
|
||||
.select({ userId: chatMessageMentions.userId })
|
||||
.from(chatMessageMentions)
|
||||
.innerJoin(
|
||||
chatMessages,
|
||||
and(
|
||||
eq(chatMessages.workspaceId, chatMessageMentions.workspaceId),
|
||||
eq(chatMessages.id, chatMessageMentions.messageId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessageMentions.workspaceId, scope.workspaceId),
|
||||
eq(chatMessageMentions.messageId, scope.messageId),
|
||||
eq(chatMessages.workspaceId, scope.workspaceId),
|
||||
eq(chatMessages.channelId, scope.channelId),
|
||||
),
|
||||
);
|
||||
return rows.map(({ userId }) => userId);
|
||||
},
|
||||
async loadSendFacts(scope) {
|
||||
const [member] = await tx
|
||||
.select({ role: workspaceMembers.role })
|
||||
.from(workspaceMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMembers.workspaceId, scope.workspaceId),
|
||||
eq(workspaceMembers.userId, scope.actorUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const [channel] = await tx
|
||||
.select()
|
||||
.from(chatChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(chatChannels.workspaceId, scope.workspaceId),
|
||||
eq(chatChannels.id, scope.channelId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!member || !channel) return null;
|
||||
const [channelMember] = await tx
|
||||
.select({ userId: chatChannelMembers.userId })
|
||||
.from(chatChannelMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(chatChannelMembers.workspaceId, scope.workspaceId),
|
||||
eq(chatChannelMembers.channelId, scope.channelId),
|
||||
eq(chatChannelMembers.userId, scope.actorUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const role = member.role;
|
||||
if (
|
||||
!(["owner", "admin", "member", "guest"] as const).includes(
|
||||
role as never,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
actor: {
|
||||
userId: scope.actorUserId,
|
||||
workspaceId: scope.workspaceId,
|
||||
activeWorkspaceMember: true,
|
||||
role: role as "owner" | "admin" | "member" | "guest",
|
||||
},
|
||||
channel: {
|
||||
id: channel.id,
|
||||
workspaceId: channel.workspaceId,
|
||||
kind: channel.kind,
|
||||
archived: channel.archivedAt !== null,
|
||||
activeMember: Boolean(channelMember),
|
||||
ownerUserId: channel.ownerUserId,
|
||||
},
|
||||
};
|
||||
},
|
||||
async loadParent(scope) {
|
||||
const [parent] = await tx
|
||||
.select({
|
||||
workspaceId: chatMessages.workspaceId,
|
||||
channelId: chatMessages.channelId,
|
||||
parentMessageId: chatMessages.parentMessageId,
|
||||
})
|
||||
.from(chatMessages)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessages.workspaceId, scope.workspaceId),
|
||||
eq(chatMessages.channelId, scope.channelId),
|
||||
eq(chatMessages.id, scope.messageId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return parent ?? null;
|
||||
},
|
||||
async validateMentionMembers(scope) {
|
||||
if (!scope.userIds.length) return [];
|
||||
const rows = await tx
|
||||
.select({ userId: workspaceMembers.userId })
|
||||
.from(workspaceMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMembers.workspaceId, scope.workspaceId),
|
||||
inArray(workspaceMembers.userId, scope.userIds),
|
||||
),
|
||||
);
|
||||
return rows.map(({ userId }) => userId);
|
||||
},
|
||||
async allocateSequence(scope) {
|
||||
const [row] = await tx
|
||||
.update(chatChannels)
|
||||
.set({ nextSequence: sql`${chatChannels.nextSequence} + 1` })
|
||||
.where(
|
||||
and(
|
||||
eq(chatChannels.workspaceId, scope.workspaceId),
|
||||
eq(chatChannels.id, scope.channelId),
|
||||
),
|
||||
)
|
||||
.returning({ sequence: sql<bigint>`${chatChannels.nextSequence} - 1` });
|
||||
if (!row)
|
||||
throw new Error("Channel disappeared during message transaction");
|
||||
return row.sequence;
|
||||
},
|
||||
async insertMessage(values) {
|
||||
const [message] = await tx
|
||||
.insert(chatMessages)
|
||||
.values({
|
||||
...values,
|
||||
authorType: "human",
|
||||
authorAgentId: null,
|
||||
})
|
||||
.returning();
|
||||
if (!message) throw new Error("Message insert returned no row");
|
||||
return { ...values, id: message.id, sequence: message.sequence };
|
||||
},
|
||||
async insertMentions(values) {
|
||||
await tx.insert(chatMessageMentions).values(
|
||||
values.userIds.map((userId) => ({
|
||||
workspaceId: values.workspaceId,
|
||||
messageId: values.messageId,
|
||||
userId,
|
||||
})),
|
||||
);
|
||||
},
|
||||
async insertOutbox(values) {
|
||||
await tx.insert(chatOutbox).values(values);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function retryable(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const candidate = error as { code?: unknown; cause?: unknown };
|
||||
return (
|
||||
candidate.code === "40001" ||
|
||||
candidate.code === "40P01" ||
|
||||
(candidate.cause !== undefined && retryable(candidate.cause))
|
||||
);
|
||||
}
|
||||
|
||||
export function createChatTransactionRunner(
|
||||
database: ChatDatabase = defaultDb,
|
||||
): ChatTransactionRunner {
|
||||
return {
|
||||
async transaction(run) {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
return await database.transaction((tx) =>
|
||||
run(transactionAdapter(tx)),
|
||||
);
|
||||
} catch (error) {
|
||||
if (attempt === 2 || !retryable(error)) throw error;
|
||||
}
|
||||
}
|
||||
throw new Error("Unreachable transaction retry state");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendChatMessageWithDb(
|
||||
input: Parameters<typeof sendChatMessage>[1],
|
||||
database: ChatDatabase = defaultDb,
|
||||
) {
|
||||
return sendChatMessage(createChatTransactionRunner(database), input);
|
||||
}
|
||||
7
packages/database/src/chat/index.ts
Normal file
7
packages/database/src/chat/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export * from "./channel-drizzle-adapter";
|
||||
export * from "./drizzle-adapter";
|
||||
export * from "./manage-channels";
|
||||
export * from "./mutate-message";
|
||||
export * from "./message-drizzle-adapter";
|
||||
export * from "./read-history";
|
||||
export * from "./send-message";
|
||||
297
packages/database/src/chat/manage-channels.test.ts
Normal file
297
packages/database/src/chat/manage-channels.test.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addPrivateMember,
|
||||
createChannel,
|
||||
createDirectChannel,
|
||||
removePrivateMember,
|
||||
renameChannel,
|
||||
type ChannelRecord,
|
||||
type ChannelTransaction,
|
||||
} from "./manage-channels";
|
||||
const workspaceId = "11111111-1111-4111-8111-111111111111",
|
||||
actorUserId = "22222222-2222-4222-8222-222222222222",
|
||||
target = "33333333-3333-4333-8333-333333333333",
|
||||
channelId = "44444444-4444-4444-8444-444444444444",
|
||||
clientMutationId = "55555555-5555-4555-8555-555555555555";
|
||||
const channel = (patch: Partial<ChannelRecord> = {}): ChannelRecord => ({
|
||||
id: channelId,
|
||||
workspaceId,
|
||||
kind: "private",
|
||||
name: "private",
|
||||
ownerUserId: actorUserId,
|
||||
directKey: null,
|
||||
archivedAt: null,
|
||||
...patch,
|
||||
});
|
||||
function seam(patch: Partial<ChannelTransaction> = {}) {
|
||||
const events: unknown[] = [];
|
||||
const tx: ChannelTransaction = {
|
||||
loadActor: async () => ({ userId: actorUserId, role: "member" }),
|
||||
listVisible: async () => [],
|
||||
lockChannel: async () => channel(),
|
||||
isChannelMember: async () => true,
|
||||
areWorkspaceMembers: async () => true,
|
||||
insertChannel: async () => channel(),
|
||||
renameActiveChannel: async (_w, _c, name) => channel({ name }),
|
||||
archiveActiveChannel: async () => channel({ archivedAt: new Date() }),
|
||||
addMember: async () => true,
|
||||
removeMember: async () => true,
|
||||
lockDirect: async () => {},
|
||||
findDirect: async () => null,
|
||||
insertDirect: async (input) =>
|
||||
channel({ kind: "direct", name: null, directKey: input.directKey }),
|
||||
addInitialDirectMembers: async () => {},
|
||||
finalizeDirect: async (_w, _c, directKey) =>
|
||||
channel({ kind: "direct", name: null, directKey }),
|
||||
listMemberIds: async () => [],
|
||||
lockMutation: async () => {},
|
||||
loadReceipt: async () => null,
|
||||
saveReceipt: async () => {},
|
||||
emit: async (e) => {
|
||||
events.push(e);
|
||||
},
|
||||
...patch,
|
||||
};
|
||||
return {
|
||||
run: async <T>(fn: (tx: ChannelTransaction) => Promise<T>) => fn(tx),
|
||||
events,
|
||||
tx,
|
||||
};
|
||||
}
|
||||
const scope = { workspaceId, actorUserId, clientMutationId };
|
||||
describe("channel lifecycle orchestration", () => {
|
||||
it("does not emit for idempotent membership add/remove", async () => {
|
||||
const s = seam({
|
||||
addMember: async () => false,
|
||||
removeMember: async () => false,
|
||||
});
|
||||
await addPrivateMember(s.run, { ...scope, channelId, userId: target });
|
||||
await removePrivateMember(s.run, { ...scope, channelId, userId: target });
|
||||
expect(s.events).toEqual([]);
|
||||
});
|
||||
it("emits membership events only when add and remove change rows", async () => {
|
||||
const s = seam();
|
||||
await addPrivateMember(s.run, { ...scope, channelId, userId: target });
|
||||
await removePrivateMember(s.run, { ...scope, channelId, userId: target });
|
||||
expect(s.events).toMatchObject([
|
||||
{ eventType: "chat.channel.member.added.v1", subjectUserId: target },
|
||||
{ eventType: "chat.channel.member.removed.v1", subjectUserId: target },
|
||||
]);
|
||||
});
|
||||
it("protects the private owner", async () => {
|
||||
const s = seam();
|
||||
await expect(
|
||||
removePrivateMember(s.run, { ...scope, channelId, userId: actorUserId }),
|
||||
).rejects.toMatchObject({ code: "INVALID" });
|
||||
});
|
||||
it("gives workspace admins no private membership bypass", async () => {
|
||||
const s = seam({
|
||||
loadActor: async () => ({ userId: actorUserId, role: "admin" }),
|
||||
isChannelMember: async () => false,
|
||||
});
|
||||
await expect(
|
||||
addPrivateMember(s.run, { ...scope, channelId, userId: target }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
it("denies mutation after archive", async () => {
|
||||
const s = seam({
|
||||
lockChannel: async () => channel({ archivedAt: new Date() }),
|
||||
});
|
||||
await expect(
|
||||
renameChannel(s.run, { ...scope, channelId, name: "next" }),
|
||||
).rejects.toMatchObject({ code: "READ_ONLY" });
|
||||
});
|
||||
it("canonicalizes and deduplicates direct participants and orders events", async () => {
|
||||
let inserted: string[] = [];
|
||||
const s = seam({
|
||||
addInitialDirectMembers: async (_w, _c, ids) => {
|
||||
inserted = ids;
|
||||
},
|
||||
});
|
||||
await createDirectChannel(s.run, {
|
||||
...scope,
|
||||
participantUserIds: [target, actorUserId, target],
|
||||
});
|
||||
expect(inserted).toEqual([actorUserId, target].sort());
|
||||
expect(s.events).toHaveLength(3);
|
||||
});
|
||||
it("requires actor and two distinct participants", async () => {
|
||||
const s = seam();
|
||||
await expect(
|
||||
createDirectChannel(s.run, {
|
||||
...scope,
|
||||
participantUserIds: [target, target],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "INVALID" });
|
||||
});
|
||||
it("rejects an existing direct with a mismatched exact set", async () => {
|
||||
const s = seam({
|
||||
findDirect: async () =>
|
||||
channel({ kind: "direct", name: null, directKey: "x" }),
|
||||
listMemberIds: async () => [actorUserId],
|
||||
});
|
||||
await expect(
|
||||
createDirectChannel(s.run, {
|
||||
...scope,
|
||||
participantUserIds: [actorUserId, target],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "CONFLICT" });
|
||||
});
|
||||
it("denies replay of a private channel receipt after membership revocation", async () => {
|
||||
const original = channel();
|
||||
const s = seam({
|
||||
loadReceipt: async () => ({
|
||||
operation: "create_channel",
|
||||
fingerprint: createHash("sha256")
|
||||
.update(JSON.stringify({ kind: "private", name: "private" }))
|
||||
.digest("hex"),
|
||||
result: { operation: "create_channel", channel: original },
|
||||
}),
|
||||
isChannelMember: async () => false,
|
||||
});
|
||||
await expect(
|
||||
createChannel(s.run, { ...scope, kind: "private", name: "private" }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(s.events).toEqual([]);
|
||||
});
|
||||
it("denies replay of a private member receipt after membership revocation", async () => {
|
||||
const s = seam({
|
||||
loadReceipt: async () => ({
|
||||
operation: "add_private_member",
|
||||
fingerprint: createHash("sha256")
|
||||
.update(JSON.stringify({ channelId, userId: target }))
|
||||
.digest("hex"),
|
||||
result: {
|
||||
operation: "add_private_member",
|
||||
member: { channelId, userId: target, changed: true },
|
||||
},
|
||||
}),
|
||||
isChannelMember: async () => false,
|
||||
});
|
||||
await expect(
|
||||
addPrivateMember(s.run, { ...scope, channelId, userId: target }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
it("does not disclose a saved remove-self result after leaving a private channel", async () => {
|
||||
const s = seam({
|
||||
lockChannel: async () => channel({ ownerUserId: target }),
|
||||
loadReceipt: async () => ({
|
||||
operation: "remove_private_member",
|
||||
fingerprint: createHash("sha256")
|
||||
.update(JSON.stringify({ channelId, userId: actorUserId }))
|
||||
.digest("hex"),
|
||||
result: {
|
||||
operation: "remove_private_member",
|
||||
member: { channelId, userId: actorUserId, changed: true },
|
||||
},
|
||||
}),
|
||||
isChannelMember: async () => false,
|
||||
});
|
||||
await expect(
|
||||
removePrivateMember(s.run, { ...scope, channelId, userId: actorUserId }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
it("allows public receipt replay without channel membership", async () => {
|
||||
const original = channel({ kind: "public" });
|
||||
const s = seam({
|
||||
lockChannel: async () => original,
|
||||
isChannelMember: async () => false,
|
||||
loadReceipt: async () => ({
|
||||
operation: "create_channel",
|
||||
fingerprint: createHash("sha256")
|
||||
.update(JSON.stringify({ kind: "public", name: "private" }))
|
||||
.digest("hex"),
|
||||
result: { operation: "create_channel", channel: original },
|
||||
}),
|
||||
});
|
||||
await expect(
|
||||
createChannel(s.run, { ...scope, kind: "public", name: "private" }),
|
||||
).resolves.toMatchObject({ id: channelId });
|
||||
});
|
||||
it("denies replay of a direct channel receipt after membership revocation", async () => {
|
||||
const participants = [actorUserId, target].sort();
|
||||
const original = channel({ kind: "direct", name: null, directKey: "key" });
|
||||
const s = seam({
|
||||
lockChannel: async () => original,
|
||||
isChannelMember: async () => false,
|
||||
loadReceipt: async () => ({
|
||||
operation: "create_direct",
|
||||
fingerprint: createHash("sha256")
|
||||
.update(JSON.stringify({ participantUserIds: participants }))
|
||||
.digest("hex"),
|
||||
result: { operation: "create_direct", channel: original },
|
||||
}),
|
||||
});
|
||||
await expect(
|
||||
createDirectChannel(s.run, {
|
||||
...scope,
|
||||
participantUserIds: participants,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
it("replays readable archived channels without rechecking write permissions", async () => {
|
||||
const archived = channel({ archivedAt: new Date("2026-01-01T00:00:00Z") });
|
||||
const s = seam({
|
||||
lockChannel: async () => archived,
|
||||
loadReceipt: async () => ({
|
||||
operation: "rename_channel",
|
||||
fingerprint: createHash("sha256")
|
||||
.update(JSON.stringify({ channelId, name: "next" }))
|
||||
.digest("hex"),
|
||||
result: {
|
||||
operation: "rename_channel",
|
||||
channel: {
|
||||
...archived,
|
||||
archivedAt: archived.archivedAt?.toISOString(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
await expect(
|
||||
renameChannel(s.run, { ...scope, channelId, name: "next" }),
|
||||
).resolves.toMatchObject({ id: channelId, name: "private" });
|
||||
expect(s.events).toEqual([]);
|
||||
});
|
||||
it("replays a matching receipt without state or event calls", async () => {
|
||||
const original = channel();
|
||||
const insert = vi.fn();
|
||||
const fingerprint = createHash("sha256")
|
||||
.update(JSON.stringify({ kind: "public", name: "private" }))
|
||||
.digest("hex");
|
||||
const s = seam({
|
||||
loadReceipt: async () => ({
|
||||
operation: "create_channel",
|
||||
fingerprint,
|
||||
result: {
|
||||
operation: "create_channel",
|
||||
channel: { ...original, archivedAt: null },
|
||||
},
|
||||
}),
|
||||
insertChannel: insert,
|
||||
});
|
||||
const result = await createChannel(s.run, {
|
||||
...scope,
|
||||
kind: "public",
|
||||
name: "private",
|
||||
});
|
||||
expect(result.id).toBe(channelId);
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(s.events).toEqual([]);
|
||||
});
|
||||
it("rejects receipt intent collisions before state calls", async () => {
|
||||
const lockChannel = vi.fn();
|
||||
const s = seam({
|
||||
loadReceipt: async () => ({
|
||||
operation: "archive_channel",
|
||||
fingerprint: "different",
|
||||
result: {},
|
||||
}),
|
||||
lockChannel,
|
||||
});
|
||||
await expect(
|
||||
renameChannel(s.run, { ...scope, channelId, name: "next" }),
|
||||
).rejects.toMatchObject({ code: "CONFLICT" });
|
||||
expect(lockChannel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
479
packages/database/src/chat/manage-channels.ts
Normal file
479
packages/database/src/chat/manage-channels.ts
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
chatChannelLifecycleEventV1Schema,
|
||||
chatChannelNameSchema,
|
||||
chatLifecycleReceiptResultSchema,
|
||||
type ChatChannelLifecycleEventV1,
|
||||
type ChatLifecycleReceiptResult,
|
||||
} from "@tasks/shared";
|
||||
|
||||
export type ChannelKind = "public" | "private" | "direct";
|
||||
export type ChannelRecord = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
kind: ChannelKind;
|
||||
name: string | null;
|
||||
ownerUserId: string;
|
||||
directKey: string | null;
|
||||
archivedAt: Date | null;
|
||||
};
|
||||
export type WorkspaceActor = { userId: string; role: string };
|
||||
export class ChannelPersistenceError extends Error {
|
||||
constructor(
|
||||
public readonly code: "NOT_FOUND" | "INVALID" | "READ_ONLY" | "CONFLICT",
|
||||
) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
export type MutationScope = {
|
||||
workspaceId: string;
|
||||
actorUserId: string;
|
||||
clientMutationId: string;
|
||||
};
|
||||
export type ChannelTransaction = {
|
||||
loadActor(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
): Promise<WorkspaceActor | null>;
|
||||
listVisible(
|
||||
workspaceId: string,
|
||||
actorUserId: string,
|
||||
): Promise<ChannelRecord[]>;
|
||||
lockChannel(
|
||||
workspaceId: string,
|
||||
channelId: string,
|
||||
): Promise<ChannelRecord | null>;
|
||||
isChannelMember(
|
||||
workspaceId: string,
|
||||
channelId: string,
|
||||
userId: string,
|
||||
): Promise<boolean>;
|
||||
areWorkspaceMembers(workspaceId: string, userIds: string[]): Promise<boolean>;
|
||||
insertChannel(
|
||||
input: Omit<ChannelRecord, "id" | "archivedAt">,
|
||||
): Promise<ChannelRecord>;
|
||||
renameActiveChannel(
|
||||
workspaceId: string,
|
||||
channelId: string,
|
||||
name: string,
|
||||
): Promise<ChannelRecord | null>;
|
||||
archiveActiveChannel(
|
||||
workspaceId: string,
|
||||
channelId: string,
|
||||
): Promise<ChannelRecord | null>;
|
||||
addMember(
|
||||
workspaceId: string,
|
||||
channelId: string,
|
||||
userId: string,
|
||||
): Promise<boolean>;
|
||||
removeMember(
|
||||
workspaceId: string,
|
||||
channelId: string,
|
||||
userId: string,
|
||||
): Promise<boolean>;
|
||||
lockDirect(workspaceId: string, directKey: string): Promise<void>;
|
||||
findDirect(
|
||||
workspaceId: string,
|
||||
directKey: string,
|
||||
): Promise<ChannelRecord | null>;
|
||||
insertDirect(
|
||||
input: Omit<ChannelRecord, "id" | "archivedAt">,
|
||||
): Promise<ChannelRecord | null>;
|
||||
addInitialDirectMembers(
|
||||
workspaceId: string,
|
||||
channelId: string,
|
||||
userIds: string[],
|
||||
): Promise<void>;
|
||||
finalizeDirect(
|
||||
workspaceId: string,
|
||||
channelId: string,
|
||||
directKey: string,
|
||||
): Promise<ChannelRecord>;
|
||||
listMemberIds(workspaceId: string, channelId: string): Promise<string[]>;
|
||||
lockMutation(scope: MutationScope): Promise<void>;
|
||||
loadReceipt(scope: MutationScope): Promise<{
|
||||
operation: string;
|
||||
fingerprint: string;
|
||||
result: unknown;
|
||||
} | null>;
|
||||
saveReceipt(
|
||||
scope: MutationScope,
|
||||
operation: string,
|
||||
fingerprint: string,
|
||||
result: unknown,
|
||||
): Promise<void>;
|
||||
emit(event: ChatChannelLifecycleEventV1): Promise<void>;
|
||||
};
|
||||
export type ChannelRunner = <T>(
|
||||
fn: (tx: ChannelTransaction) => Promise<T>,
|
||||
) => Promise<T>;
|
||||
const isAdmin = (role: string) => role === "admin" || role === "owner";
|
||||
async function requireActor(tx: ChannelTransaction, scope: MutationScope) {
|
||||
const actor = await tx.loadActor(scope.workspaceId, scope.actorUserId);
|
||||
if (!actor) throw new ChannelPersistenceError("NOT_FOUND");
|
||||
return actor;
|
||||
}
|
||||
async function lockMutable(
|
||||
tx: ChannelTransaction,
|
||||
scope: MutationScope,
|
||||
channelId: string,
|
||||
) {
|
||||
const [actor, channel] = await Promise.all([
|
||||
requireActor(tx, scope),
|
||||
tx.lockChannel(scope.workspaceId, channelId),
|
||||
]);
|
||||
if (!channel) throw new ChannelPersistenceError("NOT_FOUND");
|
||||
if (channel.archivedAt) throw new ChannelPersistenceError("READ_ONLY");
|
||||
return { actor, channel };
|
||||
}
|
||||
async function requirePrivateManager(
|
||||
tx: ChannelTransaction,
|
||||
scope: MutationScope,
|
||||
channelId: string,
|
||||
) {
|
||||
const facts = await lockMutable(tx, scope, channelId);
|
||||
if (
|
||||
facts.channel.kind !== "private" ||
|
||||
!(await tx.isChannelMember(
|
||||
scope.workspaceId,
|
||||
channelId,
|
||||
scope.actorUserId,
|
||||
)) ||
|
||||
(facts.channel.ownerUserId !== scope.actorUserId &&
|
||||
!isAdmin(facts.actor.role))
|
||||
)
|
||||
throw new ChannelPersistenceError("NOT_FOUND");
|
||||
return facts.channel;
|
||||
}
|
||||
function parseEvent(event: ChatChannelLifecycleEventV1) {
|
||||
return chatChannelLifecycleEventV1Schema.parse(event);
|
||||
}
|
||||
async function withReceipt(
|
||||
tx: ChannelTransaction,
|
||||
scope: MutationScope,
|
||||
operation: ChatLifecycleReceiptResult["operation"],
|
||||
intent: unknown,
|
||||
execute: () => Promise<ChatLifecycleReceiptResult>,
|
||||
): Promise<ChatLifecycleReceiptResult> {
|
||||
const fingerprint = createHash("sha256")
|
||||
.update(JSON.stringify(intent))
|
||||
.digest("hex");
|
||||
await tx.lockMutation(scope);
|
||||
const receipt = await tx.loadReceipt(scope);
|
||||
if (receipt) {
|
||||
if (receipt.operation !== operation || receipt.fingerprint !== fingerprint)
|
||||
throw new ChannelPersistenceError("CONFLICT");
|
||||
const result = chatLifecycleReceiptResultSchema.parse(receipt.result);
|
||||
const channelId =
|
||||
"channel" in result ? result.channel.id : result.member.channelId;
|
||||
const channel = await tx.lockChannel(scope.workspaceId, channelId);
|
||||
if (
|
||||
!channel ||
|
||||
("channel" in result &&
|
||||
result.channel.workspaceId !== scope.workspaceId) ||
|
||||
(channel.kind !== "public" &&
|
||||
!(await tx.isChannelMember(
|
||||
scope.workspaceId,
|
||||
channelId,
|
||||
scope.actorUserId,
|
||||
)))
|
||||
)
|
||||
throw new ChannelPersistenceError("NOT_FOUND");
|
||||
return result;
|
||||
}
|
||||
const result = chatLifecycleReceiptResultSchema.parse(await execute());
|
||||
if (result.operation !== operation)
|
||||
throw new ChannelPersistenceError("CONFLICT");
|
||||
await tx.saveReceipt(scope, operation, fingerprint, result);
|
||||
return result;
|
||||
}
|
||||
function channelResult(channel: ChannelRecord) {
|
||||
return { ...channel, archivedAt: channel.archivedAt?.toISOString() ?? null };
|
||||
}
|
||||
function resultChannel(result: ChatLifecycleReceiptResult): ChannelRecord {
|
||||
if (!("channel" in result)) throw new ChannelPersistenceError("CONFLICT");
|
||||
return {
|
||||
...result.channel,
|
||||
archivedAt: result.channel.archivedAt
|
||||
? new Date(result.channel.archivedAt)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
function eventBase(scope: MutationScope, channelId: string) {
|
||||
return {
|
||||
eventVersion: 1 as const,
|
||||
workspaceId: scope.workspaceId,
|
||||
channelId,
|
||||
actorUserId: scope.actorUserId,
|
||||
clientMutationId: scope.clientMutationId,
|
||||
};
|
||||
}
|
||||
|
||||
export const listVisibleChannels = (
|
||||
run: ChannelRunner,
|
||||
scope: Omit<MutationScope, "clientMutationId">,
|
||||
) =>
|
||||
run(async (tx) => {
|
||||
if (!(await tx.loadActor(scope.workspaceId, scope.actorUserId)))
|
||||
throw new ChannelPersistenceError("NOT_FOUND");
|
||||
return tx.listVisible(scope.workspaceId, scope.actorUserId);
|
||||
});
|
||||
export const createChannel = (
|
||||
run: ChannelRunner,
|
||||
scope: MutationScope & { kind: "public" | "private"; name: string },
|
||||
) =>
|
||||
run(async (tx) => {
|
||||
await requireActor(tx, scope);
|
||||
const name = chatChannelNameSchema.parse(scope.name);
|
||||
const result = await withReceipt(
|
||||
tx,
|
||||
scope,
|
||||
"create_channel",
|
||||
{ kind: scope.kind, name },
|
||||
async () => {
|
||||
const channel = await tx.insertChannel({
|
||||
workspaceId: scope.workspaceId,
|
||||
kind: scope.kind,
|
||||
name,
|
||||
ownerUserId: scope.actorUserId,
|
||||
directKey: null,
|
||||
});
|
||||
if (scope.kind === "private")
|
||||
await tx.addMember(scope.workspaceId, channel.id, scope.actorUserId);
|
||||
await tx.emit(
|
||||
parseEvent({
|
||||
...eventBase(scope, channel.id),
|
||||
eventType: "chat.channel.created.v1",
|
||||
kind: scope.kind,
|
||||
}),
|
||||
);
|
||||
if (scope.kind === "private")
|
||||
await tx.emit(
|
||||
parseEvent({
|
||||
...eventBase(scope, channel.id),
|
||||
eventType: "chat.channel.member.added.v1",
|
||||
subjectUserId: scope.actorUserId,
|
||||
}),
|
||||
);
|
||||
return { operation: "create_channel", channel: channelResult(channel) };
|
||||
},
|
||||
);
|
||||
return resultChannel(result);
|
||||
});
|
||||
export const renameChannel = (
|
||||
run: ChannelRunner,
|
||||
scope: MutationScope & { channelId: string; name: string },
|
||||
) =>
|
||||
run(async (tx) => {
|
||||
await requireActor(tx, scope);
|
||||
const name = chatChannelNameSchema.parse(scope.name);
|
||||
const result = await withReceipt(
|
||||
tx,
|
||||
scope,
|
||||
"rename_channel",
|
||||
{ channelId: scope.channelId, name },
|
||||
async () => {
|
||||
const { actor, channel } = await lockMutable(
|
||||
tx,
|
||||
scope,
|
||||
scope.channelId,
|
||||
);
|
||||
if (
|
||||
channel.kind === "direct" ||
|
||||
(channel.ownerUserId !== scope.actorUserId && !isAdmin(actor.role)) ||
|
||||
(channel.kind === "private" &&
|
||||
!(await tx.isChannelMember(
|
||||
scope.workspaceId,
|
||||
channel.id,
|
||||
scope.actorUserId,
|
||||
)))
|
||||
)
|
||||
throw new ChannelPersistenceError("NOT_FOUND");
|
||||
let updated = channel;
|
||||
if (channel.name !== name) {
|
||||
const changed = await tx.renameActiveChannel(
|
||||
scope.workspaceId,
|
||||
channel.id,
|
||||
name,
|
||||
);
|
||||
if (!changed) throw new ChannelPersistenceError("READ_ONLY");
|
||||
updated = changed;
|
||||
await tx.emit(
|
||||
parseEvent({
|
||||
...eventBase(scope, channel.id),
|
||||
eventType: "chat.channel.renamed.v1",
|
||||
name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return { operation: "rename_channel", channel: channelResult(updated) };
|
||||
},
|
||||
);
|
||||
return resultChannel(result);
|
||||
});
|
||||
export const archiveChannel = (
|
||||
run: ChannelRunner,
|
||||
scope: MutationScope & { channelId: string },
|
||||
) =>
|
||||
run(async (tx) => {
|
||||
await requireActor(tx, scope);
|
||||
const result = await withReceipt(
|
||||
tx,
|
||||
scope,
|
||||
"archive_channel",
|
||||
{ channelId: scope.channelId },
|
||||
async () => {
|
||||
const { actor, channel } = await lockMutable(
|
||||
tx,
|
||||
scope,
|
||||
scope.channelId,
|
||||
);
|
||||
if (
|
||||
channel.kind === "direct" ||
|
||||
(channel.ownerUserId !== scope.actorUserId && !isAdmin(actor.role)) ||
|
||||
(channel.kind === "private" &&
|
||||
!(await tx.isChannelMember(
|
||||
scope.workspaceId,
|
||||
channel.id,
|
||||
scope.actorUserId,
|
||||
)))
|
||||
)
|
||||
throw new ChannelPersistenceError("NOT_FOUND");
|
||||
const updated = await tx.archiveActiveChannel(
|
||||
scope.workspaceId,
|
||||
channel.id,
|
||||
);
|
||||
if (!updated) throw new ChannelPersistenceError("READ_ONLY");
|
||||
await tx.emit(
|
||||
parseEvent({
|
||||
...eventBase(scope, channel.id),
|
||||
eventType: "chat.channel.archived.v1",
|
||||
}),
|
||||
);
|
||||
return {
|
||||
operation: "archive_channel",
|
||||
channel: channelResult(updated),
|
||||
};
|
||||
},
|
||||
);
|
||||
return resultChannel(result);
|
||||
});
|
||||
async function memberMutation(
|
||||
tx: ChannelTransaction,
|
||||
scope: MutationScope & { channelId: string; userId: string },
|
||||
action: "add_private_member" | "remove_private_member",
|
||||
) {
|
||||
await requireActor(tx, scope);
|
||||
return withReceipt(
|
||||
tx,
|
||||
scope,
|
||||
action,
|
||||
{ channelId: scope.channelId, userId: scope.userId },
|
||||
async () => {
|
||||
const channel = await requirePrivateManager(tx, scope, scope.channelId);
|
||||
if (
|
||||
action === "add_private_member" &&
|
||||
!(await tx.areWorkspaceMembers(scope.workspaceId, [scope.userId]))
|
||||
)
|
||||
throw new ChannelPersistenceError("NOT_FOUND");
|
||||
if (
|
||||
action === "remove_private_member" &&
|
||||
channel.ownerUserId === scope.userId
|
||||
)
|
||||
throw new ChannelPersistenceError("INVALID");
|
||||
const changed =
|
||||
action === "add_private_member"
|
||||
? await tx.addMember(scope.workspaceId, channel.id, scope.userId)
|
||||
: await tx.removeMember(scope.workspaceId, channel.id, scope.userId);
|
||||
if (changed)
|
||||
await tx.emit(
|
||||
parseEvent({
|
||||
...eventBase(scope, channel.id),
|
||||
eventType:
|
||||
action === "add_private_member"
|
||||
? "chat.channel.member.added.v1"
|
||||
: "chat.channel.member.removed.v1",
|
||||
subjectUserId: scope.userId,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
operation: action,
|
||||
member: { channelId: channel.id, userId: scope.userId, changed },
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
export const addPrivateMember = (
|
||||
run: ChannelRunner,
|
||||
scope: MutationScope & { channelId: string; userId: string },
|
||||
) => run((tx) => memberMutation(tx, scope, "add_private_member"));
|
||||
export const removePrivateMember = (
|
||||
run: ChannelRunner,
|
||||
scope: MutationScope & { channelId: string; userId: string },
|
||||
) => run((tx) => memberMutation(tx, scope, "remove_private_member"));
|
||||
export const createDirectChannel = (
|
||||
run: ChannelRunner,
|
||||
scope: MutationScope & { participantUserIds: string[] },
|
||||
) =>
|
||||
run(async (tx) => {
|
||||
await requireActor(tx, scope);
|
||||
const ids = [...new Set(scope.participantUserIds)].sort();
|
||||
const result = await withReceipt(
|
||||
tx,
|
||||
scope,
|
||||
"create_direct",
|
||||
{ participantUserIds: ids },
|
||||
async () => {
|
||||
if (
|
||||
ids.length < 2 ||
|
||||
!ids.includes(scope.actorUserId) ||
|
||||
!(await tx.areWorkspaceMembers(scope.workspaceId, ids))
|
||||
)
|
||||
throw new ChannelPersistenceError("INVALID");
|
||||
const key = createHash("md5").update(ids.join(":")).digest("hex");
|
||||
await tx.lockDirect(scope.workspaceId, key);
|
||||
let channel = await tx.findDirect(scope.workspaceId, key);
|
||||
if (channel) {
|
||||
const existing = (
|
||||
await tx.listMemberIds(scope.workspaceId, channel.id)
|
||||
).sort();
|
||||
if (
|
||||
existing.length !== ids.length ||
|
||||
existing.some((id, i) => id !== ids[i])
|
||||
)
|
||||
throw new ChannelPersistenceError("CONFLICT");
|
||||
return {
|
||||
operation: "create_direct",
|
||||
channel: channelResult(channel),
|
||||
};
|
||||
}
|
||||
channel = await tx.insertDirect({
|
||||
workspaceId: scope.workspaceId,
|
||||
kind: "direct",
|
||||
name: null,
|
||||
ownerUserId: scope.actorUserId,
|
||||
directKey: key,
|
||||
});
|
||||
if (!channel) throw new ChannelPersistenceError("CONFLICT");
|
||||
await tx.addInitialDirectMembers(scope.workspaceId, channel.id, ids);
|
||||
channel = await tx.finalizeDirect(scope.workspaceId, channel.id, key);
|
||||
await tx.emit(
|
||||
parseEvent({
|
||||
...eventBase(scope, channel.id),
|
||||
eventType: "chat.channel.created.v1",
|
||||
kind: "direct",
|
||||
}),
|
||||
);
|
||||
for (const id of ids)
|
||||
await tx.emit(
|
||||
parseEvent({
|
||||
...eventBase(scope, channel.id),
|
||||
eventType: "chat.channel.member.added.v1",
|
||||
subjectUserId: id,
|
||||
}),
|
||||
);
|
||||
return { operation: "create_direct", channel: channelResult(channel) };
|
||||
},
|
||||
);
|
||||
return resultChannel(result);
|
||||
});
|
||||
53
packages/database/src/chat/message-drizzle-adapter.test.ts
Normal file
53
packages/database/src/chat/message-drizzle-adapter.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import { chatOutbox } from "../schema";
|
||||
import { createMessageMutationRunner } from "./message-drizzle-adapter";
|
||||
|
||||
type Database = NonNullable<Parameters<typeof createMessageMutationRunner>[0]>;
|
||||
type Tx = Parameters<Parameters<Database["transaction"]>[0]>[0];
|
||||
|
||||
describe("created outbox redaction", () => {
|
||||
it("binds plain JSON metadata and scopes redaction to the workspace/channel/message/create event", async () => {
|
||||
const where = vi.fn(async () => undefined);
|
||||
const set = vi.fn(() => ({ where }));
|
||||
const update = vi.fn(() => ({ set }));
|
||||
const tx = { update } as unknown as Tx;
|
||||
const database = {
|
||||
transaction: async (run: (tx: Tx) => Promise<unknown>) => run(tx),
|
||||
} as unknown as Database;
|
||||
const scope = {
|
||||
workspaceId: "10000000-0000-4000-8000-000000000001",
|
||||
channelId: "20000000-0000-4000-8000-000000000001",
|
||||
messageId: "30000000-0000-4000-8000-000000000001",
|
||||
};
|
||||
await createMessageMutationRunner(database).transaction((adapter) =>
|
||||
adapter.redactCreatedOutbox(scope),
|
||||
);
|
||||
expect(update).toHaveBeenCalledWith(chatOutbox);
|
||||
expect(set).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
eventType: "chat.message.created.v1",
|
||||
eventVersion: 1,
|
||||
...scope,
|
||||
},
|
||||
});
|
||||
const condition = (
|
||||
where.mock.calls as unknown as [[Parameters<PgDialect["sqlToQuery"]>[0]]]
|
||||
)[0][0];
|
||||
const query = new PgDialect().sqlToQuery(condition);
|
||||
expect(query.params).toEqual([
|
||||
scope.workspaceId,
|
||||
scope.channelId,
|
||||
scope.messageId,
|
||||
"chat.message.created.v1",
|
||||
]);
|
||||
for (const column of [
|
||||
"workspace_id",
|
||||
"channel_id",
|
||||
"message_id",
|
||||
"event_type",
|
||||
]) {
|
||||
expect(query.sql).toContain(`"${column}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
253
packages/database/src/chat/message-drizzle-adapter.ts
Normal file
253
packages/database/src/chat/message-drizzle-adapter.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { chatMessageMutationEventV1Schema } from "@tasks/shared";
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
|
||||
import { db as defaultDb } from "../client";
|
||||
import {
|
||||
chatChannels,
|
||||
chatMessageMutations,
|
||||
chatMessageMentions,
|
||||
chatMessages,
|
||||
chatMutationReceipts,
|
||||
chatOutbox,
|
||||
} from "../schema";
|
||||
import { transactionAdapter } from "./drizzle-adapter";
|
||||
import {
|
||||
mutateChatMessage,
|
||||
type MessageMutationRunner,
|
||||
type MessageMutationTransaction,
|
||||
} from "./mutate-message";
|
||||
|
||||
// Reuse the send adapter's tenant-scoped membership lookup; mutations use the same channel policy.
|
||||
type Db = typeof defaultDb;
|
||||
type Tx = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
function adapter(tx: Tx): MessageMutationTransaction {
|
||||
return {
|
||||
async claim(scope) {
|
||||
const lockKey = `${scope.workspaceId}:${scope.actorUserId}:${scope.clientMutationId}`;
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`,
|
||||
);
|
||||
const [receipt] = await tx
|
||||
.select()
|
||||
.from(chatMutationReceipts)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMutationReceipts.workspaceId, scope.workspaceId),
|
||||
eq(chatMutationReceipts.actorUserId, scope.actorUserId),
|
||||
eq(chatMutationReceipts.clientMutationId, scope.clientMutationId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return receipt ?? null;
|
||||
},
|
||||
async facts(scope) {
|
||||
// This is deliberately the same member/channel lookup as send, not a role-based shortcut.
|
||||
return transactionAdapter(tx).loadSendFacts(scope);
|
||||
},
|
||||
async lockMessage(scope) {
|
||||
const [message] = await tx
|
||||
.select({
|
||||
id: chatMessages.id,
|
||||
workspaceId: chatMessages.workspaceId,
|
||||
channelId: chatMessages.channelId,
|
||||
authorUserId: chatMessages.authorUserId,
|
||||
status: chatMessages.status,
|
||||
revision: chatMessages.revision,
|
||||
})
|
||||
.from(chatMessages)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessages.workspaceId, scope.workspaceId),
|
||||
eq(chatMessages.channelId, scope.channelId),
|
||||
eq(chatMessages.id, scope.messageId),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.limit(1);
|
||||
return message ?? null;
|
||||
},
|
||||
async pinLegacySendIntent(scope) {
|
||||
const [message] = await tx
|
||||
.select({
|
||||
body: chatMessages.body,
|
||||
authorUserId: chatMessages.authorUserId,
|
||||
parentMessageId: chatMessages.parentMessageId,
|
||||
sendIntentFingerprint: chatMessages.sendIntentFingerprint,
|
||||
})
|
||||
.from(chatMessages)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessages.workspaceId, scope.workspaceId),
|
||||
eq(chatMessages.channelId, scope.channelId),
|
||||
eq(chatMessages.id, scope.messageId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!message || message.sendIntentFingerprint) return;
|
||||
const mentions = await tx
|
||||
.select({ userId: chatMessageMentions.userId })
|
||||
.from(chatMessageMentions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessageMentions.workspaceId, scope.workspaceId),
|
||||
eq(chatMessageMentions.messageId, scope.messageId),
|
||||
),
|
||||
);
|
||||
const fingerprint = createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify([
|
||||
scope.workspaceId,
|
||||
scope.channelId,
|
||||
message.authorUserId,
|
||||
message.body,
|
||||
message.parentMessageId,
|
||||
mentions.map((row) => row.userId).sort(),
|
||||
]),
|
||||
)
|
||||
.digest("hex");
|
||||
await tx
|
||||
.update(chatMessages)
|
||||
.set({ sendIntentFingerprint: fingerprint })
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessages.workspaceId, scope.workspaceId),
|
||||
eq(chatMessages.channelId, scope.channelId),
|
||||
eq(chatMessages.id, scope.messageId),
|
||||
),
|
||||
);
|
||||
},
|
||||
async apply(scope) {
|
||||
const [updated] = await tx
|
||||
.update(chatMessages)
|
||||
.set(
|
||||
scope.type === "edit_message"
|
||||
? {
|
||||
body: scope.body!,
|
||||
status: "edited",
|
||||
editedAt: new Date(),
|
||||
revision: scope.revision,
|
||||
}
|
||||
: {
|
||||
body: "",
|
||||
status: "deleted",
|
||||
deletedAt: new Date(),
|
||||
revision: scope.revision,
|
||||
},
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessages.workspaceId, scope.workspaceId),
|
||||
eq(chatMessages.channelId, scope.channelId),
|
||||
eq(chatMessages.id, scope.messageId),
|
||||
eq(chatMessages.revision, scope.revision - 1),
|
||||
),
|
||||
)
|
||||
.returning({ id: chatMessages.id });
|
||||
if (!updated) throw new Error("Concurrent message update");
|
||||
const [channel] = await tx
|
||||
.update(chatChannels)
|
||||
.set({
|
||||
nextMutationSequence: sql`${chatChannels.nextMutationSequence} + 1`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatChannels.workspaceId, scope.workspaceId),
|
||||
eq(chatChannels.id, scope.channelId),
|
||||
),
|
||||
)
|
||||
.returning({ nextMutationSequence: chatChannels.nextMutationSequence });
|
||||
if (!channel) throw new Error("Channel disappeared during mutation");
|
||||
const status = scope.type === "edit_message" ? "edited" : "deleted";
|
||||
await tx.insert(chatMessageMutations).values({
|
||||
workspaceId: scope.workspaceId,
|
||||
channelId: scope.channelId,
|
||||
messageId: scope.messageId,
|
||||
mutationSequence: channel.nextMutationSequence - 1n,
|
||||
revision: scope.revision,
|
||||
kind: status,
|
||||
});
|
||||
return { messageId: updated.id, revision: scope.revision, status };
|
||||
},
|
||||
async redactCreatedOutbox(scope) {
|
||||
// Historical create payloads contain the original body. Strip them before publishing
|
||||
// a mutation so a delayed worker cannot replay stale content after edit/delete.
|
||||
await tx
|
||||
.update(chatOutbox)
|
||||
.set({
|
||||
// Plain JSON — avoid jsonb_build_object() params (Postgres: could not determine data type of parameter $1).
|
||||
payload: {
|
||||
eventType: "chat.message.created.v1",
|
||||
eventVersion: 1,
|
||||
workspaceId: scope.workspaceId,
|
||||
channelId: scope.channelId,
|
||||
messageId: scope.messageId,
|
||||
},
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatOutbox.workspaceId, scope.workspaceId),
|
||||
eq(chatOutbox.channelId, scope.channelId),
|
||||
eq(chatOutbox.messageId, scope.messageId),
|
||||
eq(chatOutbox.eventType, "chat.message.created.v1"),
|
||||
),
|
||||
);
|
||||
},
|
||||
async insertOutbox(scope) {
|
||||
await tx.insert(chatOutbox).values({
|
||||
workspaceId: scope.workspaceId,
|
||||
channelId: scope.channelId,
|
||||
messageId: scope.messageId,
|
||||
eventVersion: 1,
|
||||
eventType: scope.eventType,
|
||||
dedupeKey: `mutation:${scope.actorUserId}:${scope.clientMutationId}`,
|
||||
payload: chatMessageMutationEventV1Schema.parse({
|
||||
eventType: scope.eventType,
|
||||
eventVersion: 1,
|
||||
workspaceId: scope.workspaceId,
|
||||
channelId: scope.channelId,
|
||||
messageId: scope.messageId,
|
||||
revision: scope.result.revision,
|
||||
status: scope.result.status,
|
||||
}),
|
||||
});
|
||||
},
|
||||
async saveReceipt(scope) {
|
||||
await tx.insert(chatMutationReceipts).values({ ...scope });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function retryable(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const e = error as { code?: unknown; cause?: unknown };
|
||||
return (
|
||||
e.code === "40001" ||
|
||||
e.code === "40P01" ||
|
||||
(e.cause !== undefined && retryable(e.cause))
|
||||
);
|
||||
}
|
||||
export function createMessageMutationRunner(
|
||||
database: Db = defaultDb,
|
||||
): MessageMutationRunner {
|
||||
return {
|
||||
async transaction(run) {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
return await database.transaction((tx) => run(adapter(tx)), {
|
||||
isolationLevel: "repeatable read",
|
||||
});
|
||||
} catch (error) {
|
||||
if (attempt === 2 || !retryable(error)) throw error;
|
||||
}
|
||||
}
|
||||
throw new Error("Unreachable retry state");
|
||||
},
|
||||
};
|
||||
}
|
||||
export async function mutateChatMessageWithDb(
|
||||
input: Parameters<typeof mutateChatMessage>[1],
|
||||
database: Db = defaultDb,
|
||||
) {
|
||||
return mutateChatMessage(createMessageMutationRunner(database), input);
|
||||
}
|
||||
181
packages/database/src/chat/mutate-message.test.ts
Normal file
181
packages/database/src/chat/mutate-message.test.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mutateChatMessage,
|
||||
type MessageMutationTransaction,
|
||||
} from "./mutate-message";
|
||||
|
||||
const workspaceId = "11111111-1111-4111-8111-111111111111";
|
||||
const channelId = "22222222-2222-4222-8222-222222222222";
|
||||
const actorUserId = "33333333-3333-4333-8333-333333333333";
|
||||
const messageId = "44444444-4444-4444-8444-444444444444";
|
||||
const clientMutationId = "55555555-5555-4555-8555-555555555555";
|
||||
const command = (type: "edit_message" | "delete_message", extra = {}) => ({
|
||||
type,
|
||||
workspaceId,
|
||||
channelId,
|
||||
context: { type: "workspace", workspaceId },
|
||||
messageId,
|
||||
expectedRevision: 1,
|
||||
clientMutationId,
|
||||
...(type === "edit_message" ? { body: "updated" } : {}),
|
||||
...extra,
|
||||
});
|
||||
function setup(
|
||||
options: {
|
||||
kind?: "public" | "private";
|
||||
member?: boolean;
|
||||
archived?: boolean;
|
||||
author?: string;
|
||||
receipt?: boolean;
|
||||
revision?: number;
|
||||
} = {},
|
||||
) {
|
||||
const calls: string[] = [];
|
||||
const tx: MessageMutationTransaction = {
|
||||
claim: async () => {
|
||||
calls.push("claim");
|
||||
return options.receipt
|
||||
? {
|
||||
operation: "edit_message",
|
||||
fingerprint: "mismatch",
|
||||
result: { revision: 2 },
|
||||
}
|
||||
: null;
|
||||
},
|
||||
facts: async () => {
|
||||
calls.push("facts");
|
||||
return {
|
||||
actor: {
|
||||
userId: actorUserId,
|
||||
workspaceId,
|
||||
activeWorkspaceMember: true,
|
||||
role: "admin" as const,
|
||||
},
|
||||
channel: {
|
||||
id: channelId,
|
||||
workspaceId,
|
||||
kind: options.kind ?? "public",
|
||||
activeMember: options.member ?? false,
|
||||
archived: options.archived ?? false,
|
||||
ownerUserId: actorUserId,
|
||||
},
|
||||
};
|
||||
},
|
||||
lockMessage: async () => {
|
||||
calls.push("lock");
|
||||
return {
|
||||
id: messageId,
|
||||
workspaceId,
|
||||
channelId,
|
||||
authorUserId: options.author ?? actorUserId,
|
||||
status: "sent" as const,
|
||||
revision: options.revision ?? 1,
|
||||
};
|
||||
},
|
||||
pinLegacySendIntent: async () => {
|
||||
calls.push("pin");
|
||||
},
|
||||
apply: async () => {
|
||||
calls.push("apply");
|
||||
return { messageId, revision: 2, status: "edited" as const };
|
||||
},
|
||||
redactCreatedOutbox: async () => {
|
||||
calls.push("redact");
|
||||
},
|
||||
insertOutbox: async () => {
|
||||
calls.push("outbox");
|
||||
},
|
||||
saveReceipt: async () => {
|
||||
calls.push("receipt");
|
||||
},
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
runner: {
|
||||
transaction: async <T>(
|
||||
fn: (tx: MessageMutationTransaction) => Promise<T>,
|
||||
) => fn(tx),
|
||||
},
|
||||
};
|
||||
}
|
||||
describe("message mutations", () => {
|
||||
it("rejects a private-channel nonmember even if admin", async () => {
|
||||
const { runner, calls } = setup({ kind: "private" });
|
||||
await expect(
|
||||
mutateChatMessage(runner, {
|
||||
actorUserId,
|
||||
command: command("edit_message"),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "CHAT_FORBIDDEN" });
|
||||
expect(calls).not.toContain("apply");
|
||||
});
|
||||
it("does not grant admin moderation over another author's message", async () => {
|
||||
const { runner, calls } = setup({
|
||||
author: "66666666-6666-4666-8666-666666666666",
|
||||
});
|
||||
await expect(
|
||||
mutateChatMessage(runner, {
|
||||
actorUserId,
|
||||
command: command("delete_message"),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "CHAT_FORBIDDEN" });
|
||||
expect(calls).not.toContain("apply");
|
||||
});
|
||||
it("rejects archived writes", async () => {
|
||||
const { runner } = setup({ archived: true });
|
||||
await expect(
|
||||
mutateChatMessage(runner, {
|
||||
actorUserId,
|
||||
command: command("delete_message"),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "CHAT_FORBIDDEN" });
|
||||
});
|
||||
it("requires matching revision before writing", async () => {
|
||||
const { runner, calls } = setup({ revision: 3 });
|
||||
await expect(
|
||||
mutateChatMessage(runner, {
|
||||
actorUserId,
|
||||
command: command("edit_message"),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "CHAT_REVISION_CONFLICT" });
|
||||
expect(calls).not.toContain("apply");
|
||||
});
|
||||
it("rejects reused mutation IDs before writing", async () => {
|
||||
const { runner, calls } = setup({ receipt: true });
|
||||
await expect(
|
||||
mutateChatMessage(runner, {
|
||||
actorUserId,
|
||||
command: command("edit_message"),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "CHAT_IDEMPOTENCY_CONFLICT" });
|
||||
expect(calls).not.toContain("apply");
|
||||
});
|
||||
it("redacts queued created events in the same transaction", async () => {
|
||||
const { runner, calls } = setup();
|
||||
await mutateChatMessage(runner, {
|
||||
actorUserId,
|
||||
command: command("edit_message"),
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
"claim",
|
||||
"facts",
|
||||
"lock",
|
||||
"pin",
|
||||
"apply",
|
||||
"redact",
|
||||
"outbox",
|
||||
"receipt",
|
||||
]);
|
||||
});
|
||||
it("rejects object-scoped commands at validation", async () => {
|
||||
const { runner } = setup();
|
||||
await expect(
|
||||
mutateChatMessage(runner, {
|
||||
actorUserId,
|
||||
command: command("edit_message", {
|
||||
context: { type: "object", objectId: messageId },
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
249
packages/database/src/chat/mutate-message.ts
Normal file
249
packages/database/src/chat/mutate-message.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
canPerformChatAction,
|
||||
chatCommandSchema,
|
||||
type ChatCommand,
|
||||
type ChatPolicyInput,
|
||||
} from "@tasks/shared";
|
||||
import { z } from "zod";
|
||||
|
||||
const commandSchema = chatCommandSchema.refine(
|
||||
(
|
||||
command,
|
||||
): command is Extract<
|
||||
ChatCommand,
|
||||
{ type: "edit_message" | "delete_message" }
|
||||
> => command.type === "edit_message" || command.type === "delete_message",
|
||||
"Expected message mutation",
|
||||
);
|
||||
type Command = z.infer<typeof commandSchema>;
|
||||
type Facts = Pick<
|
||||
Extract<ChatPolicyInput, { action: "send" }>,
|
||||
"actor" | "channel"
|
||||
>;
|
||||
export type MutationResult = {
|
||||
messageId: string;
|
||||
revision: number;
|
||||
status: "edited" | "deleted";
|
||||
};
|
||||
export type MessageMutationTransaction = {
|
||||
claim(scope: {
|
||||
workspaceId: string;
|
||||
actorUserId: string;
|
||||
clientMutationId: string;
|
||||
}): Promise<{
|
||||
operation: string;
|
||||
fingerprint: string;
|
||||
result: unknown;
|
||||
} | null>;
|
||||
facts(scope: {
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
actorUserId: string;
|
||||
}): Promise<Facts | null>;
|
||||
lockMessage(scope: {
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
authorUserId: string | null;
|
||||
status: string;
|
||||
revision: number;
|
||||
} | null>;
|
||||
pinLegacySendIntent(scope: {
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
}): Promise<void>;
|
||||
apply(scope: {
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
revision: number;
|
||||
type: Command["type"];
|
||||
body?: string;
|
||||
}): Promise<MutationResult>;
|
||||
redactCreatedOutbox(scope: {
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
}): Promise<void>;
|
||||
insertOutbox(scope: {
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
actorUserId: string;
|
||||
result: MutationResult;
|
||||
eventType: "chat.message.edited.v1" | "chat.message.deleted.v1";
|
||||
clientMutationId: string;
|
||||
}): Promise<void>;
|
||||
saveReceipt(scope: {
|
||||
workspaceId: string;
|
||||
actorUserId: string;
|
||||
clientMutationId: string;
|
||||
operation: string;
|
||||
fingerprint: string;
|
||||
result: MutationResult;
|
||||
}): Promise<void>;
|
||||
};
|
||||
export type MessageMutationRunner = {
|
||||
transaction<T>(
|
||||
callback: (tx: MessageMutationTransaction) => Promise<T>,
|
||||
): Promise<T>;
|
||||
};
|
||||
export class ChatMutationError extends Error {
|
||||
constructor(
|
||||
readonly code:
|
||||
| "CHAT_FORBIDDEN"
|
||||
| "CHAT_NOT_FOUND"
|
||||
| "CHAT_REVISION_CONFLICT"
|
||||
| "CHAT_IDEMPOTENCY_CONFLICT",
|
||||
) {
|
||||
super(code);
|
||||
this.name = "ChatMutationError";
|
||||
}
|
||||
}
|
||||
const resultSchema = z
|
||||
.object({
|
||||
messageId: z.string().uuid(),
|
||||
revision: z.number().int().positive(),
|
||||
status: z.enum(["edited", "deleted"]),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function mutateChatMessage(
|
||||
runner: MessageMutationRunner,
|
||||
input: { actorUserId: string; command: unknown },
|
||||
): Promise<{ changed: boolean; result: MutationResult }> {
|
||||
const actorUserId = z.string().uuid().parse(input.actorUserId);
|
||||
const command = commandSchema.parse(input.command);
|
||||
const fingerprint = createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify([
|
||||
command.type,
|
||||
command.workspaceId,
|
||||
command.channelId,
|
||||
command.messageId,
|
||||
command.expectedRevision,
|
||||
command.type === "edit_message" ? command.body : null,
|
||||
]),
|
||||
)
|
||||
.digest("hex");
|
||||
return runner.transaction(async (tx) => {
|
||||
const scope = {
|
||||
workspaceId: command.workspaceId,
|
||||
channelId: command.channelId,
|
||||
actorUserId,
|
||||
};
|
||||
const receipt = await tx.claim({
|
||||
workspaceId: command.workspaceId,
|
||||
actorUserId,
|
||||
clientMutationId: command.clientMutationId,
|
||||
});
|
||||
const facts = await tx.facts(scope);
|
||||
// Receipts remain readable after archive, but new writes cannot target an archived channel.
|
||||
if (
|
||||
!facts ||
|
||||
!canPerformChatAction({
|
||||
action: "read",
|
||||
...facts,
|
||||
context: command.context,
|
||||
}) ||
|
||||
(!receipt && facts.channel.archived)
|
||||
)
|
||||
throw new ChatMutationError("CHAT_FORBIDDEN");
|
||||
if (receipt) {
|
||||
if (
|
||||
receipt.operation !== command.type ||
|
||||
receipt.fingerprint !== fingerprint
|
||||
)
|
||||
throw new ChatMutationError("CHAT_IDEMPOTENCY_CONFLICT");
|
||||
const result = resultSchema.parse(receipt.result);
|
||||
const message = await tx.lockMessage({
|
||||
workspaceId: command.workspaceId,
|
||||
channelId: command.channelId,
|
||||
messageId: command.messageId,
|
||||
});
|
||||
if (
|
||||
!message ||
|
||||
message.authorUserId !== actorUserId ||
|
||||
result.messageId !== command.messageId ||
|
||||
result.status !==
|
||||
(command.type === "edit_message" ? "edited" : "deleted")
|
||||
)
|
||||
throw new ChatMutationError("CHAT_FORBIDDEN");
|
||||
return { changed: false, result };
|
||||
}
|
||||
const message = await tx.lockMessage({
|
||||
workspaceId: command.workspaceId,
|
||||
channelId: command.channelId,
|
||||
messageId: command.messageId,
|
||||
});
|
||||
if (
|
||||
!message ||
|
||||
message.workspaceId !== command.workspaceId ||
|
||||
message.channelId !== command.channelId
|
||||
)
|
||||
throw new ChatMutationError("CHAT_NOT_FOUND");
|
||||
if (
|
||||
!message.authorUserId ||
|
||||
!canPerformChatAction({
|
||||
action: "edit_own",
|
||||
...facts,
|
||||
context: command.context,
|
||||
message: {
|
||||
id: message.id,
|
||||
workspaceId: message.workspaceId,
|
||||
channelId: message.channelId,
|
||||
author: { type: "human", userId: message.authorUserId },
|
||||
},
|
||||
})
|
||||
)
|
||||
throw new ChatMutationError("CHAT_FORBIDDEN");
|
||||
if (
|
||||
message.revision !== command.expectedRevision ||
|
||||
message.status === "deleted"
|
||||
)
|
||||
throw new ChatMutationError("CHAT_REVISION_CONFLICT");
|
||||
await tx.pinLegacySendIntent({
|
||||
workspaceId: command.workspaceId,
|
||||
channelId: command.channelId,
|
||||
messageId: command.messageId,
|
||||
});
|
||||
const result = await tx.apply({
|
||||
workspaceId: command.workspaceId,
|
||||
channelId: command.channelId,
|
||||
messageId: command.messageId,
|
||||
revision: command.expectedRevision + 1,
|
||||
type: command.type,
|
||||
...(command.type === "edit_message" ? { body: command.body } : {}),
|
||||
});
|
||||
await tx.redactCreatedOutbox({
|
||||
workspaceId: command.workspaceId,
|
||||
channelId: command.channelId,
|
||||
messageId: command.messageId,
|
||||
});
|
||||
await tx.insertOutbox({
|
||||
...scope,
|
||||
messageId: command.messageId,
|
||||
clientMutationId: command.clientMutationId,
|
||||
result,
|
||||
eventType:
|
||||
command.type === "edit_message"
|
||||
? "chat.message.edited.v1"
|
||||
: "chat.message.deleted.v1",
|
||||
});
|
||||
await tx.saveReceipt({
|
||||
workspaceId: command.workspaceId,
|
||||
actorUserId,
|
||||
clientMutationId: command.clientMutationId,
|
||||
operation: command.type,
|
||||
fingerprint,
|
||||
result,
|
||||
});
|
||||
return { changed: true, result };
|
||||
});
|
||||
}
|
||||
405
packages/database/src/chat/read-history.postgres.test.ts
Normal file
405
packages/database/src/chat/read-history.postgres.test.ts
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import postgres from "postgres";
|
||||
|
||||
import * as schema from "../schema";
|
||||
import {
|
||||
chatChannelMembers,
|
||||
chatChannels,
|
||||
chatMessageMutations,
|
||||
chatMessageMentions,
|
||||
chatMessages,
|
||||
chatOutbox,
|
||||
chatReadCursors,
|
||||
users,
|
||||
workspaceMembers,
|
||||
workspaces,
|
||||
} from "../schema";
|
||||
import {
|
||||
chatCatchUp,
|
||||
chatMutationCatchUp,
|
||||
chatHistory,
|
||||
chatMarkChannelRead,
|
||||
chatThreadHistory,
|
||||
chatUnreadSummary,
|
||||
ChatReadError,
|
||||
} from "./read-history";
|
||||
|
||||
// Run explicitly against a disposable PostgreSQL instance with migrations through 0023.
|
||||
const url = process.env.PG_URL;
|
||||
const client = url ? postgres(url, { max: 12 }) : null;
|
||||
const db = client ? drizzle(client, { schema }) : null;
|
||||
afterAll(async () => {
|
||||
await client?.end();
|
||||
});
|
||||
|
||||
describe.skipIf(!url)("chat read APIs on PostgreSQL (0023)", () => {
|
||||
it("recovers mutation pages with current safe projections and denies private/direct outsiders", async () => {
|
||||
if (!db) throw new Error("PG_URL is required");
|
||||
const [owner, reader] = await db.insert(users).values([
|
||||
{ email: `mutation-owner-${crypto.randomUUID()}@example.test` },
|
||||
{ email: `mutation-reader-${crypto.randomUUID()}@example.test` },
|
||||
]).returning();
|
||||
const [workspace] = await db.insert(workspaces).values({
|
||||
slug: `mutation-${crypto.randomUUID()}`, name: "Mutation PG", ownerUserId: owner.id,
|
||||
}).returning();
|
||||
await db.insert(workspaceMembers).values([owner, reader].map((user) => ({
|
||||
workspaceId: workspace.id, userId: user.id, role: "member" as const,
|
||||
})));
|
||||
const [channel, hidden, direct] = await db.insert(chatChannels).values([
|
||||
{ workspaceId: workspace.id, ownerUserId: owner.id, kind: "private", name: "private", nextSequence: 3n, nextMutationSequence: 4n },
|
||||
{ workspaceId: workspace.id, ownerUserId: owner.id, kind: "private", name: "hidden" },
|
||||
{ workspaceId: workspace.id, ownerUserId: owner.id, kind: "direct", directKey: `${owner.id}:${reader.id}` },
|
||||
]).returning();
|
||||
await db.insert(chatChannelMembers).values([
|
||||
{ workspaceId: workspace.id, channelId: channel.id, userId: owner.id },
|
||||
{ workspaceId: workspace.id, channelId: hidden.id, userId: owner.id },
|
||||
{ workspaceId: workspace.id, channelId: direct.id, userId: owner.id },
|
||||
]);
|
||||
const [edited, deleted] = await db.insert(chatMessages).values([
|
||||
{ workspaceId: workspace.id, channelId: channel.id, sequence: 1n, authorType: "human", authorUserId: owner.id, body: "latest", status: "edited", revision: 4, clientIdempotencyKey: crypto.randomUUID() },
|
||||
{ workspaceId: workspace.id, channelId: channel.id, sequence: 2n, authorType: "human", authorUserId: owner.id, body: "stored secret", status: "deleted", revision: 3, clientIdempotencyKey: crypto.randomUUID() },
|
||||
]).returning();
|
||||
await db.insert(chatMessageMutations).values([
|
||||
{ workspaceId: workspace.id, channelId: channel.id, messageId: edited.id, mutationSequence: 1n, revision: 2, kind: "edited" },
|
||||
{ workspaceId: workspace.id, channelId: channel.id, messageId: edited.id, mutationSequence: 2n, revision: 4, kind: "edited" },
|
||||
{ workspaceId: workspace.id, channelId: channel.id, messageId: deleted.id, mutationSequence: 3n, revision: 3, kind: "deleted" },
|
||||
]);
|
||||
const scope = { workspaceId: workspace.id, channelId: channel.id, actorUserId: owner.id };
|
||||
const first = await chatMutationCatchUp(db, { ...scope, after: "0", limit: 1 });
|
||||
expect(first).toMatchObject({ next: "1", through: "3", hasMore: true,
|
||||
mutations: [{ messageId: edited.id, mutationSequence: "1", revision: 2 }],
|
||||
messages: [{ id: edited.id, body: "latest", revision: 4 }],
|
||||
});
|
||||
expect(JSON.stringify(first)).not.toContain("clientIdempotencyKey");
|
||||
const second = await chatMutationCatchUp(db, { ...scope, after: first.next, through: first.through, limit: 2 });
|
||||
expect(second).toMatchObject({ next: "3", through: "3", hasMore: false,
|
||||
mutations: [{ messageId: edited.id }, { messageId: deleted.id }],
|
||||
messages: [{ id: edited.id, body: "latest" }, { id: deleted.id, body: "", status: "deleted" }],
|
||||
});
|
||||
expect(JSON.stringify(second)).not.toContain("stored secret");
|
||||
await expect(chatMutationCatchUp(db, { ...scope, actorUserId: reader.id, after: "0", limit: 1 })).rejects.toBeInstanceOf(ChatReadError);
|
||||
for (const channelId of [hidden.id, direct.id]) {
|
||||
await expect(chatMutationCatchUp(db, { ...scope, channelId, actorUserId: reader.id, after: "0", limit: 1 })).rejects.toBeInstanceOf(ChatReadError);
|
||||
}
|
||||
await expect(chatMutationCatchUp(db, { ...scope, channelId: hidden.id, after: "0", through: "3", limit: 1 })).rejects.toBeInstanceOf(ChatReadError);
|
||||
});
|
||||
it("paginates roots and replies, catches up, counts unread, and protects archived/private channels", async () => {
|
||||
if (!db) throw new Error("PG_URL is required");
|
||||
const [owner, reader, outsider] = await db
|
||||
.insert(users)
|
||||
.values([
|
||||
{ email: `chat-pg-owner-${crypto.randomUUID()}@example.test` },
|
||||
{ email: `chat-pg-reader-${crypto.randomUUID()}@example.test` },
|
||||
{ email: `chat-pg-outsider-${crypto.randomUUID()}@example.test` },
|
||||
])
|
||||
.returning();
|
||||
const [workspace] = await db
|
||||
.insert(workspaces)
|
||||
.values({
|
||||
slug: `chat-pg-${crypto.randomUUID()}`,
|
||||
name: "Chat PG",
|
||||
ownerUserId: owner.id,
|
||||
})
|
||||
.returning();
|
||||
await db.insert(workspaceMembers).values(
|
||||
[owner, reader, outsider].map((user) => ({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
})),
|
||||
);
|
||||
const [publicChannel, archived, hidden] = await db
|
||||
.insert(chatChannels)
|
||||
.values([
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
ownerUserId: owner.id,
|
||||
kind: "public",
|
||||
name: "general",
|
||||
nextSequence: 5n,
|
||||
},
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
ownerUserId: owner.id,
|
||||
kind: "private",
|
||||
name: "old",
|
||||
archivedAt: new Date(),
|
||||
nextSequence: 2n,
|
||||
},
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
ownerUserId: owner.id,
|
||||
kind: "private",
|
||||
name: "secret",
|
||||
nextSequence: 2n,
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
await db.insert(chatChannelMembers).values([
|
||||
{ workspaceId: workspace.id, channelId: archived.id, userId: owner.id },
|
||||
{ workspaceId: workspace.id, channelId: archived.id, userId: reader.id },
|
||||
{ workspaceId: workspace.id, channelId: hidden.id, userId: owner.id },
|
||||
]);
|
||||
const scope = {
|
||||
workspaceId: workspace.id,
|
||||
actorUserId: reader.id,
|
||||
channelId: publicChannel.id,
|
||||
};
|
||||
const [root, reply, tombstone, secondRoot] = await db
|
||||
.insert(chatMessages)
|
||||
.values([
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
channelId: publicChannel.id,
|
||||
sequence: 1n,
|
||||
authorType: "human",
|
||||
authorUserId: owner.id,
|
||||
body: "root",
|
||||
clientIdempotencyKey: crypto.randomUUID(),
|
||||
},
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
channelId: publicChannel.id,
|
||||
sequence: 2n,
|
||||
authorType: "human",
|
||||
authorUserId: owner.id,
|
||||
body: "reply",
|
||||
clientIdempotencyKey: crypto.randomUUID(),
|
||||
},
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
channelId: publicChannel.id,
|
||||
sequence: 3n,
|
||||
authorType: "human",
|
||||
authorUserId: owner.id,
|
||||
body: "",
|
||||
status: "deleted",
|
||||
clientIdempotencyKey: crypto.randomUUID(),
|
||||
},
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
channelId: publicChannel.id,
|
||||
sequence: 4n,
|
||||
authorType: "human",
|
||||
authorUserId: owner.id,
|
||||
body: "new root",
|
||||
clientIdempotencyKey: crypto.randomUUID(),
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
await db
|
||||
.update(chatMessages)
|
||||
.set({ parentMessageId: root.id })
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessages.workspaceId, workspace.id),
|
||||
eq(chatMessages.id, reply.id),
|
||||
),
|
||||
);
|
||||
await db.insert(chatMessageMentions).values({
|
||||
workspaceId: workspace.id,
|
||||
messageId: secondRoot.id,
|
||||
userId: reader.id,
|
||||
});
|
||||
await db.insert(chatMessages).values([
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
channelId: archived.id,
|
||||
sequence: 1n,
|
||||
authorType: "human",
|
||||
authorUserId: owner.id,
|
||||
body: "archived",
|
||||
clientIdempotencyKey: crypto.randomUUID(),
|
||||
},
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
channelId: hidden.id,
|
||||
sequence: 1n,
|
||||
authorType: "human",
|
||||
authorUserId: owner.id,
|
||||
body: "secret",
|
||||
clientIdempotencyKey: crypto.randomUUID(),
|
||||
},
|
||||
]);
|
||||
try {
|
||||
const page = await chatHistory(db, { ...scope, limit: 1 });
|
||||
expect(page.messages.map((m) => m.sequence)).toEqual(["4"]);
|
||||
expect(page.next).toBe("4");
|
||||
expect(page.hasMore).toBe(true);
|
||||
const older = await chatHistory(db, { ...scope, before: "4", limit: 2 });
|
||||
expect(older.messages.map((m) => m.sequence)).toEqual(["3", "1"]);
|
||||
expect(older.messages[1]).toMatchObject({
|
||||
replyCount: 1,
|
||||
latestReplySequence: "2",
|
||||
});
|
||||
const thread = await chatThreadHistory(db, {
|
||||
...scope,
|
||||
rootMessageId: root.id,
|
||||
limit: 1,
|
||||
});
|
||||
expect(thread.root).toMatchObject({ sequence: "1", replyCount: 1 });
|
||||
expect(thread.replies.map((m) => m.sequence)).toEqual(["2"]);
|
||||
const catchUp = await chatCatchUp(db, { ...scope, after: "1", limit: 2 });
|
||||
expect(catchUp.messages.map((m) => [m.sequence, m.status])).toEqual([
|
||||
["2", "sent"],
|
||||
["3", "deleted"],
|
||||
]);
|
||||
expect(catchUp).toMatchObject({ next: "3", hasMore: true, through: "4" });
|
||||
const readCursorBefore = await db
|
||||
.select()
|
||||
.from(chatReadCursors)
|
||||
.where(eq(chatReadCursors.workspaceId, workspace.id));
|
||||
const readOutboxBefore = await db
|
||||
.select()
|
||||
.from(chatOutbox)
|
||||
.where(eq(chatOutbox.workspaceId, workspace.id));
|
||||
const bounded = await chatCatchUp(db, {
|
||||
...scope,
|
||||
after: "1",
|
||||
through: catchUp.through,
|
||||
limit: 1,
|
||||
});
|
||||
expect(bounded).toMatchObject({ next: "2", through: "4", hasMore: true });
|
||||
const tail = await chatCatchUp(db, {
|
||||
...scope,
|
||||
after: "3",
|
||||
through: bounded.through,
|
||||
limit: 5,
|
||||
});
|
||||
expect(tail).toMatchObject({ next: "4", through: "4", hasMore: false });
|
||||
await expect(
|
||||
chatCatchUp(db, { ...scope, after: "0", through: "5", limit: 2 }),
|
||||
).rejects.toBeInstanceOf(ChatReadError);
|
||||
await expect(
|
||||
chatCatchUp(db, { ...scope, after: "5", limit: 2 }),
|
||||
).rejects.toBeInstanceOf(ChatReadError);
|
||||
expect(
|
||||
(
|
||||
await chatCatchUp(db, { ...scope, after: "3", limit: 2 })
|
||||
).messages.map((m) => m.sequence),
|
||||
).toEqual(["4"]);
|
||||
expect(
|
||||
await db
|
||||
.select()
|
||||
.from(chatReadCursors)
|
||||
.where(eq(chatReadCursors.workspaceId, workspace.id)),
|
||||
).toEqual(readCursorBefore);
|
||||
expect(
|
||||
await db
|
||||
.select()
|
||||
.from(chatOutbox)
|
||||
.where(eq(chatOutbox.workspaceId, workspace.id)),
|
||||
).toEqual(readOutboxBefore);
|
||||
const summary = await chatUnreadSummary(db, {
|
||||
workspaceId: workspace.id,
|
||||
actorUserId: reader.id,
|
||||
});
|
||||
expect(summary.channels).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
channelId: publicChannel.id,
|
||||
archived: false,
|
||||
readSequence: "0",
|
||||
unreadCount: 4,
|
||||
mentionCount: 1,
|
||||
},
|
||||
{
|
||||
channelId: archived.id,
|
||||
archived: true,
|
||||
readSequence: "0",
|
||||
unreadCount: 1,
|
||||
mentionCount: 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(summary.channels).toHaveLength(2);
|
||||
expect(
|
||||
(
|
||||
await chatHistory(db, { ...scope, channelId: archived.id, limit: 10 })
|
||||
).messages.map((m) => m.body),
|
||||
).toEqual(["archived"]);
|
||||
await expect(
|
||||
chatHistory(db, { ...scope, channelId: hidden.id, limit: 10 }),
|
||||
).rejects.toBeInstanceOf(ChatReadError);
|
||||
await expect(
|
||||
chatMarkChannelRead(db, { ...scope, sequence: "5" }),
|
||||
).rejects.toBeInstanceOf(ChatReadError);
|
||||
expect(
|
||||
await chatMarkChannelRead(db, {
|
||||
...scope,
|
||||
channelId: archived.id,
|
||||
sequence: "1",
|
||||
}),
|
||||
).toMatchObject({ sequence: "1", changed: true });
|
||||
expect(
|
||||
(
|
||||
await chatUnreadSummary(db, {
|
||||
workspaceId: workspace.id,
|
||||
actorUserId: reader.id,
|
||||
})
|
||||
).channels.find((channel) => channel.channelId === archived.id),
|
||||
).toMatchObject({ readSequence: "1", unreadCount: 0 });
|
||||
const results = await Promise.allSettled(
|
||||
["2", "4", "3"].map((sequence) =>
|
||||
chatMarkChannelRead(db, { ...scope, sequence }),
|
||||
),
|
||||
);
|
||||
const [initialCursor] = await db
|
||||
.select()
|
||||
.from(chatReadCursors)
|
||||
.where(
|
||||
and(
|
||||
eq(chatReadCursors.workspaceId, workspace.id),
|
||||
eq(chatReadCursors.channelId, publicChannel.id),
|
||||
eq(chatReadCursors.userId, reader.id),
|
||||
),
|
||||
);
|
||||
expect(initialCursor.sequence).toBeGreaterThanOrEqual(2n);
|
||||
expect(initialCursor.sequence).toBeLessThanOrEqual(4n);
|
||||
expect(
|
||||
results
|
||||
.filter((result) => result.status === "rejected")
|
||||
.every(
|
||||
(result) =>
|
||||
result.status === "rejected" && result.reason.code === "40001",
|
||||
),
|
||||
).toBe(true);
|
||||
// The bounded transaction retry cannot lower a concurrently advanced cursor.
|
||||
const retry = await chatMarkChannelRead(db, { ...scope, sequence: "4" });
|
||||
expect(retry.sequence).toBe("4");
|
||||
expect(
|
||||
await chatMarkChannelRead(db, { ...scope, sequence: "2" }),
|
||||
).toEqual({ sequence: "4", changed: false });
|
||||
const events = await db
|
||||
.select()
|
||||
.from(chatOutbox)
|
||||
.where(
|
||||
and(
|
||||
eq(chatOutbox.workspaceId, workspace.id),
|
||||
eq(chatOutbox.channelId, publicChannel.id),
|
||||
eq(chatOutbox.eventType, "chat.read_state.changed.v1"),
|
||||
),
|
||||
);
|
||||
expect(events.length).toBe(
|
||||
results.filter(
|
||||
(result) => result.status === "fulfilled" && result.value.changed,
|
||||
).length + Number(retry.changed),
|
||||
);
|
||||
const readSummary = await chatUnreadSummary(db, {
|
||||
workspaceId: workspace.id,
|
||||
actorUserId: reader.id,
|
||||
});
|
||||
expect(
|
||||
readSummary.channels.find(
|
||||
(channel) => channel.channelId === publicChannel.id,
|
||||
),
|
||||
).toMatchObject({ readSequence: "4", unreadCount: 0, mentionCount: 0 });
|
||||
} finally {
|
||||
await db.delete(workspaces).where(eq(workspaces.id, workspace.id));
|
||||
await db
|
||||
.delete(users)
|
||||
.where(sql`${users.id} in (${owner.id}, ${reader.id}, ${outsider.id})`);
|
||||
}
|
||||
}, 20000);
|
||||
});
|
||||
277
packages/database/src/chat/read-history.test.ts
Normal file
277
packages/database/src/chat/read-history.test.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { db as defaultDb } from "../client";
|
||||
import {
|
||||
chatCatchUp,
|
||||
chatMutationCatchUp,
|
||||
chatHistory,
|
||||
chatThreadHistory,
|
||||
chatMarkChannelRead,
|
||||
ChatReadError,
|
||||
} from "./read-history";
|
||||
|
||||
import { chatCursorSchema, chatSequenceSchema } from "./read-history";
|
||||
|
||||
// Sequence/cursor inputs are canonical decimal bigint strings, not JS numbers or aliases.
|
||||
describe("chat read sequence validation", () => {
|
||||
it("accepts positive sequences and the zero reconnect cursor", () => {
|
||||
expect(chatSequenceSchema.parse("9223372036854775807")).toBe(
|
||||
"9223372036854775807",
|
||||
);
|
||||
expect(chatCursorSchema.parse("0")).toBe("0");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"00",
|
||||
"01",
|
||||
"-1",
|
||||
"+1",
|
||||
"1.0",
|
||||
" 1",
|
||||
"1e3",
|
||||
"9223372036854775808",
|
||||
1,
|
||||
])("rejects malformed cursor %s", (value) => {
|
||||
expect(chatCursorSchema.safeParse(value).success).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a positive sequence for a viewed message", () => {
|
||||
expect(chatSequenceSchema.safeParse("0").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const scope = {
|
||||
workspaceId: "11111111-1111-4111-8111-111111111111",
|
||||
channelId: "33333333-3333-4333-8333-333333333333",
|
||||
actorUserId: "22222222-2222-4222-8222-222222222222",
|
||||
};
|
||||
|
||||
describe("mutation recovery projections", () => {
|
||||
it("deduplicates repeated mutations and redacts the current tombstone", async () => {
|
||||
const messageId = "77777777-7777-4777-8777-777777777777";
|
||||
const results = [
|
||||
[{ role: "member" }],
|
||||
[{ id: scope.channelId, workspaceId: scope.workspaceId, kind: "public", ownerUserId: scope.actorUserId, archivedAt: null, nextMutationSequence: 3n }],
|
||||
[],
|
||||
[{ messageId, mutationSequence: 1n, revision: 2, kind: "edited" }, { messageId, mutationSequence: 2n, revision: 3, kind: "deleted" }],
|
||||
[{ id: messageId, workspaceId: scope.workspaceId, channelId: scope.channelId, sequence: 1n, parentMessageId: null, body: "secret", status: "deleted", revision: 3, deletedAt: new Date(), createdAt: new Date(), authorUserId: scope.actorUserId, sendIntentFingerprint: "private" }],
|
||||
];
|
||||
const tx = {
|
||||
execute: vi.fn(),
|
||||
select: () => {
|
||||
const rows = results.shift();
|
||||
const query = {
|
||||
from: () => query, where: () => query, orderBy: () => query,
|
||||
limit: () => Promise.resolve(rows),
|
||||
then: (resolve: (value: unknown) => unknown) => Promise.resolve(rows).then(resolve),
|
||||
};
|
||||
return query;
|
||||
},
|
||||
};
|
||||
const db = { transaction: (fn: (value: typeof tx) => unknown) => fn(tx) } as unknown as typeof defaultDb;
|
||||
const result = await chatMutationCatchUp(db, { ...scope, after: "0", limit: 10 });
|
||||
expect(result.mutations).toHaveLength(2);
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.messages[0]).toMatchObject({ id: messageId, revision: 3, body: "", status: "deleted" });
|
||||
expect(JSON.stringify(result)).not.toContain("secret");
|
||||
expect(JSON.stringify(result)).not.toContain("private");
|
||||
expect(result).toMatchObject({ next: "2", through: "2", hasMore: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mutation recovery input", () => {
|
||||
const db = { transaction: vi.fn() } as unknown as typeof defaultDb;
|
||||
it("rejects noncanonical cursors and inverted fixed high-water before querying", async () => {
|
||||
await expect(
|
||||
chatMutationCatchUp(db, { ...scope, after: "01", limit: 10 }),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
chatMutationCatchUp(db, {
|
||||
...scope,
|
||||
after: "3",
|
||||
through: "2",
|
||||
limit: 10,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mark-read transaction retries", () => {
|
||||
const error = (code: string) =>
|
||||
Object.assign(new Error("transaction failed"), { code });
|
||||
it.each(["40001", "40P01"])(
|
||||
"retries a failed transaction for %s",
|
||||
async (code) => {
|
||||
const transaction = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(error(code))
|
||||
.mockResolvedValueOnce({ changed: true, sequence: "9007199254740993" });
|
||||
const result = await chatMarkChannelRead(
|
||||
{ transaction } as unknown as typeof defaultDb,
|
||||
{ ...scope, sequence: "9007199254740993" },
|
||||
);
|
||||
expect(result).toEqual({ changed: true, sequence: "9007199254740993" });
|
||||
expect(transaction).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
it("does not retry unrelated errors or safe authorization failures", async () => {
|
||||
for (const failure of [error("23505"), new ChatReadError()]) {
|
||||
const transaction = vi.fn().mockRejectedValue(failure);
|
||||
await expect(
|
||||
chatMarkChannelRead({ transaction } as unknown as typeof defaultDb, {
|
||||
...scope,
|
||||
sequence: "1",
|
||||
}),
|
||||
).rejects.toBe(failure);
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
it("exhausts bounded retries without masking the database error", async () => {
|
||||
const failure = error("40001");
|
||||
const transaction = vi.fn().mockRejectedValue(failure);
|
||||
await expect(
|
||||
chatMarkChannelRead({ transaction } as unknown as typeof defaultDb, {
|
||||
...scope,
|
||||
sequence: "1",
|
||||
}),
|
||||
).rejects.toBe(failure);
|
||||
expect(transaction.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(transaction.mock.calls.length).toBeLessThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("read-history transaction retries", () => {
|
||||
const retryable = (code: string) =>
|
||||
new Error("transaction failed", {
|
||||
cause: new Error("driver wrapper", {
|
||||
cause: Object.assign(new Error("postgres failure"), { code }),
|
||||
}),
|
||||
});
|
||||
const requests = [
|
||||
{
|
||||
name: "history",
|
||||
run: (db: typeof defaultDb) => chatHistory(db, { ...scope, limit: 10 }),
|
||||
},
|
||||
{
|
||||
name: "thread history",
|
||||
run: (db: typeof defaultDb) =>
|
||||
chatThreadHistory(db, {
|
||||
...scope,
|
||||
rootMessageId: "44444444-4444-4444-8444-444444444444",
|
||||
limit: 10,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "catch-up",
|
||||
run: (db: typeof defaultDb) =>
|
||||
chatCatchUp(db, { ...scope, after: "0", limit: 10 }),
|
||||
},
|
||||
{
|
||||
name: "mark-read",
|
||||
run: (db: typeof defaultDb) =>
|
||||
chatMarkChannelRead(db, { ...scope, sequence: "1" }),
|
||||
},
|
||||
];
|
||||
|
||||
it.each(requests)(
|
||||
"$name starts a fresh transaction on a cause-wrapped serialization failure",
|
||||
async ({ run }) => {
|
||||
const callbacks: unknown[] = [];
|
||||
const transactions: unknown[] = [];
|
||||
const failure = retryable("40001");
|
||||
const transaction = vi.fn(
|
||||
async (callback: (tx: unknown) => Promise<unknown>) => {
|
||||
callbacks.push(callback);
|
||||
const tx = {
|
||||
execute: vi.fn().mockRejectedValue(failure),
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({ limit: () => Promise.reject(failure) }),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
transactions.push(tx);
|
||||
return callback(tx);
|
||||
},
|
||||
);
|
||||
await expect(
|
||||
run({ transaction } as unknown as typeof defaultDb),
|
||||
).rejects.toBe(failure);
|
||||
expect(transaction).toHaveBeenCalledTimes(3);
|
||||
expect(new Set(callbacks).size).toBe(3);
|
||||
expect(new Set(transactions).size).toBe(3);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(requests)(
|
||||
"$name retries wrapped deadlocks but not unrelated errors",
|
||||
async ({ run }) => {
|
||||
const deadlock = retryable("40P01");
|
||||
const transaction = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(deadlock)
|
||||
.mockResolvedValueOnce("committed");
|
||||
await expect(
|
||||
run({ transaction } as unknown as typeof defaultDb),
|
||||
).resolves.toBe("committed");
|
||||
expect(transaction).toHaveBeenCalledTimes(2);
|
||||
|
||||
const unrelated = retryable("23505");
|
||||
const noRetry = vi.fn().mockRejectedValue(unrelated);
|
||||
await expect(
|
||||
run({ transaction: noRetry } as unknown as typeof defaultDb),
|
||||
).rejects.toBe(unrelated);
|
||||
expect(noRetry).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not retry individual statements outside the mark-read transaction", async () => {
|
||||
const failure = retryable("40001");
|
||||
const externalEffects: number[] = [];
|
||||
const transaction = vi.fn(
|
||||
async (callback: (tx: unknown) => Promise<unknown>) => {
|
||||
const attempt = transaction.mock.calls.length;
|
||||
return callback({
|
||||
select: () => {
|
||||
externalEffects.push(attempt);
|
||||
return {
|
||||
from: () => ({
|
||||
where: () => ({ limit: () => Promise.reject(failure) }),
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
await expect(
|
||||
chatMarkChannelRead({ transaction } as unknown as typeof defaultDb, {
|
||||
...scope,
|
||||
sequence: "1",
|
||||
}),
|
||||
).rejects.toBe(failure);
|
||||
expect(externalEffects).toEqual([1, 2, 3]);
|
||||
// External side effects survive retries; cursor changes and outbox insertion must
|
||||
// remain inside the DB transaction to benefit from rollback.
|
||||
});
|
||||
});
|
||||
|
||||
describe("catch-up bounds", () => {
|
||||
it.each([
|
||||
{ after: "9223372036854775808", limit: 1 },
|
||||
{ after: "0", through: "01", limit: 1 },
|
||||
{ after: "2", through: "1", limit: 1 },
|
||||
])(
|
||||
"rejects malformed bounds before starting a transaction %#",
|
||||
async (bounds) => {
|
||||
const transaction = vi.fn();
|
||||
await expect(
|
||||
chatCatchUp({ transaction } as unknown as typeof defaultDb, {
|
||||
...scope,
|
||||
...bounds,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
617
packages/database/src/chat/read-history.ts
Normal file
617
packages/database/src/chat/read-history.ts
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
import {
|
||||
canPerformChatAction,
|
||||
chatCursorSchema,
|
||||
chatSequenceSchema,
|
||||
} from "@tasks/shared";
|
||||
import { and, asc, desc, eq, gt, inArray, isNull, lt, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
import { db as defaultDb } from "../client";
|
||||
import {
|
||||
chatChannelMembers,
|
||||
chatChannels,
|
||||
chatMessageMentions,
|
||||
chatMessageMutations,
|
||||
chatMessages,
|
||||
chatOutbox,
|
||||
chatReadCursors,
|
||||
workspaceMembers,
|
||||
} from "../schema";
|
||||
|
||||
export { chatCursorSchema, chatSequenceSchema } from "@tasks/shared";
|
||||
export type ChatSequence = z.infer<typeof chatSequenceSchema>;
|
||||
export type ChatCursor = z.infer<typeof chatCursorSchema>;
|
||||
|
||||
export const chatPageSizeSchema = z.number().int().min(1).max(100);
|
||||
export const chatCatchUpSizeSchema = z.number().int().min(1).max(500);
|
||||
const uuid = z.string().uuid();
|
||||
const scopeSchema = z
|
||||
.object({ workspaceId: uuid, channelId: uuid, actorUserId: uuid })
|
||||
.strict();
|
||||
const historySchema = scopeSchema
|
||||
.extend({ before: chatSequenceSchema.optional(), limit: chatPageSizeSchema })
|
||||
.strict();
|
||||
const threadSchema = historySchema.extend({ rootMessageId: uuid }).strict();
|
||||
const catchUpSchema = scopeSchema
|
||||
.extend({
|
||||
after: chatCursorSchema,
|
||||
through: chatCursorSchema.optional(),
|
||||
limit: chatCatchUpSizeSchema,
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(input) =>
|
||||
input.through === undefined ||
|
||||
BigInt(input.after) <= BigInt(input.through),
|
||||
{ message: "After exceeds through", path: ["through"] },
|
||||
);
|
||||
const mutationsSchema = scopeSchema
|
||||
.extend({
|
||||
after: chatCursorSchema,
|
||||
through: chatCursorSchema.optional(),
|
||||
limit: chatCatchUpSizeSchema,
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(input) =>
|
||||
input.through === undefined ||
|
||||
BigInt(input.after) <= BigInt(input.through),
|
||||
{ path: ["through"] },
|
||||
);
|
||||
const markReadSchema = scopeSchema
|
||||
.extend({ sequence: chatSequenceSchema })
|
||||
.strict();
|
||||
const summarySchema = z
|
||||
.object({ workspaceId: uuid, actorUserId: uuid })
|
||||
.strict();
|
||||
type Scope = z.infer<typeof scopeSchema>;
|
||||
type Tx = Parameters<Parameters<typeof defaultDb.transaction>[0]>[0];
|
||||
export class ChatReadError extends Error {
|
||||
constructor() {
|
||||
super("Chat channel not found");
|
||||
}
|
||||
}
|
||||
|
||||
// The same workspace+channel+actor policy guards every read surface, including archived channels.
|
||||
async function authorize(tx: Tx, scope: Scope) {
|
||||
const [actor] = await tx
|
||||
.select({ role: workspaceMembers.role })
|
||||
.from(workspaceMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMembers.workspaceId, scope.workspaceId),
|
||||
eq(workspaceMembers.userId, scope.actorUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const [channel] = await tx
|
||||
.select()
|
||||
.from(chatChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(chatChannels.workspaceId, scope.workspaceId),
|
||||
eq(chatChannels.id, scope.channelId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!actor || !channel) throw new ChatReadError();
|
||||
const [member] = await tx
|
||||
.select({ userId: chatChannelMembers.userId })
|
||||
.from(chatChannelMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(chatChannelMembers.workspaceId, scope.workspaceId),
|
||||
eq(chatChannelMembers.channelId, scope.channelId),
|
||||
eq(chatChannelMembers.userId, scope.actorUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (
|
||||
!canPerformChatAction({
|
||||
action: "read",
|
||||
actor: {
|
||||
userId: scope.actorUserId,
|
||||
workspaceId: scope.workspaceId,
|
||||
activeWorkspaceMember: true,
|
||||
role: actor.role,
|
||||
},
|
||||
context: { type: "workspace", workspaceId: scope.workspaceId },
|
||||
channel: {
|
||||
id: channel.id,
|
||||
workspaceId: channel.workspaceId,
|
||||
kind: channel.kind,
|
||||
archived: channel.archivedAt !== null,
|
||||
activeMember: !!member,
|
||||
ownerUserId: channel.ownerUserId,
|
||||
},
|
||||
})
|
||||
)
|
||||
throw new ChatReadError();
|
||||
return channel;
|
||||
}
|
||||
|
||||
type Message = typeof chatMessages.$inferSelect;
|
||||
function serialize(message: Message) {
|
||||
// Explicit projection prevents internal send-intent fingerprints (and future storage keys)
|
||||
// from leaking through history and catch-up DTOs.
|
||||
return {
|
||||
id: message.id,
|
||||
workspaceId: message.workspaceId,
|
||||
channelId: message.channelId,
|
||||
sequence: message.sequence.toString(),
|
||||
parentMessageId: message.parentMessageId,
|
||||
authorType: message.authorType,
|
||||
authorUserId: message.authorUserId,
|
||||
authorAgentId: message.authorAgentId,
|
||||
body: message.status === "deleted" ? "" : message.body,
|
||||
status: message.status,
|
||||
revision: message.revision,
|
||||
createdAt: message.createdAt,
|
||||
editedAt: message.editedAt,
|
||||
deletedAt: message.deletedAt,
|
||||
};
|
||||
}
|
||||
async function replyMetadata(tx: Tx, scope: Scope, roots: Message[]) {
|
||||
if (!roots.length)
|
||||
return new Map<
|
||||
string,
|
||||
{ replyCount: number; latestReplySequence: string | null }
|
||||
>();
|
||||
const rows = await tx
|
||||
.select({
|
||||
parentMessageId: chatMessages.parentMessageId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
latest: sql<bigint>`max(${chatMessages.sequence})`,
|
||||
})
|
||||
.from(chatMessages)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessages.workspaceId, scope.workspaceId),
|
||||
eq(chatMessages.channelId, scope.channelId),
|
||||
inArray(
|
||||
chatMessages.parentMessageId,
|
||||
roots.map((root) => root.id),
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(chatMessages.parentMessageId);
|
||||
return new Map(
|
||||
rows.map((row) => [
|
||||
row.parentMessageId!,
|
||||
{ replyCount: row.count, latestReplySequence: row.latest.toString() },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// Backward root-page snapshot: limit+1 detects older roots, while reply metadata uses the same snapshot.
|
||||
export async function chatHistory(
|
||||
db: typeof defaultDb,
|
||||
raw: z.input<typeof historySchema>,
|
||||
) {
|
||||
const input = historySchema.parse(raw);
|
||||
return retryTransaction(
|
||||
db,
|
||||
async (tx) => {
|
||||
await tx.execute(sql`set transaction read only`);
|
||||
await authorize(tx, input);
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(chatMessages)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessages.workspaceId, input.workspaceId),
|
||||
eq(chatMessages.channelId, input.channelId),
|
||||
isNull(chatMessages.parentMessageId),
|
||||
input.before
|
||||
? lt(chatMessages.sequence, BigInt(input.before))
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(chatMessages.sequence))
|
||||
.limit(input.limit + 1);
|
||||
const hasMore = rows.length > input.limit;
|
||||
const page = rows.slice(0, input.limit);
|
||||
const metadata = await replyMetadata(tx, input, page);
|
||||
return {
|
||||
messages: page.map((message) => ({
|
||||
...serialize(message),
|
||||
replyCount: metadata.get(message.id)?.replyCount ?? 0,
|
||||
latestReplySequence:
|
||||
metadata.get(message.id)?.latestReplySequence ?? null,
|
||||
})),
|
||||
next: hasMore ? page.at(-1)!.sequence.toString() : null,
|
||||
hasMore,
|
||||
};
|
||||
},
|
||||
{ isolationLevel: "repeatable read" },
|
||||
);
|
||||
}
|
||||
|
||||
// Thread pages include the root and only its direct replies; a reply is never a root.
|
||||
export async function chatThreadHistory(
|
||||
db: typeof defaultDb,
|
||||
raw: z.input<typeof threadSchema>,
|
||||
) {
|
||||
const input = threadSchema.parse(raw);
|
||||
return retryTransaction(
|
||||
db,
|
||||
async (tx) => {
|
||||
await tx.execute(sql`set transaction read only`);
|
||||
await authorize(tx, input);
|
||||
const [root] = await tx
|
||||
.select()
|
||||
.from(chatMessages)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessages.workspaceId, input.workspaceId),
|
||||
eq(chatMessages.channelId, input.channelId),
|
||||
eq(chatMessages.id, input.rootMessageId),
|
||||
isNull(chatMessages.parentMessageId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!root) throw new ChatReadError();
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(chatMessages)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessages.workspaceId, input.workspaceId),
|
||||
eq(chatMessages.channelId, input.channelId),
|
||||
eq(chatMessages.parentMessageId, root.id),
|
||||
input.before
|
||||
? lt(chatMessages.sequence, BigInt(input.before))
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(chatMessages.sequence))
|
||||
.limit(input.limit + 1);
|
||||
const hasMore = rows.length > input.limit;
|
||||
const page = rows.slice(0, input.limit);
|
||||
const metadata = await replyMetadata(tx, input, [root]);
|
||||
return {
|
||||
root: {
|
||||
...serialize(root),
|
||||
replyCount: metadata.get(root.id)?.replyCount ?? 0,
|
||||
latestReplySequence:
|
||||
metadata.get(root.id)?.latestReplySequence ?? null,
|
||||
},
|
||||
replies: page.map(serialize),
|
||||
next: hasMore ? page.at(-1)!.sequence.toString() : null,
|
||||
hasMore,
|
||||
};
|
||||
},
|
||||
{ isolationLevel: "repeatable read" },
|
||||
);
|
||||
}
|
||||
|
||||
// Bounded reconnect scan includes replies and tombstones in channel sequence order.
|
||||
export async function chatCatchUp(
|
||||
db: typeof defaultDb,
|
||||
raw: z.input<typeof catchUpSchema>,
|
||||
) {
|
||||
const input = catchUpSchema.parse(raw);
|
||||
return retryTransaction(
|
||||
db,
|
||||
async (tx) => {
|
||||
await tx.execute(sql`set transaction read only`);
|
||||
const channel = await authorize(tx, input);
|
||||
const highWater = channel.nextSequence - 1n;
|
||||
if (
|
||||
BigInt(input.after) > highWater ||
|
||||
(input.through !== undefined && BigInt(input.through) > highWater)
|
||||
)
|
||||
throw new ChatReadError();
|
||||
const through = input.through ?? highWater.toString();
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(chatMessages)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessages.workspaceId, input.workspaceId),
|
||||
eq(chatMessages.channelId, input.channelId),
|
||||
gt(chatMessages.sequence, BigInt(input.after)),
|
||||
sql`${chatMessages.sequence} <= ${BigInt(through)}`,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(chatMessages.sequence))
|
||||
.limit(input.limit + 1);
|
||||
const hasMore = rows.length > input.limit;
|
||||
const page = rows.slice(0, input.limit);
|
||||
const roots = page.filter((message) => message.parentMessageId === null);
|
||||
const metadata = await replyMetadata(tx, input, roots);
|
||||
return {
|
||||
messages: page.map((message) => ({
|
||||
...serialize(message),
|
||||
replyCount:
|
||||
message.parentMessageId === null
|
||||
? (metadata.get(message.id)?.replyCount ?? 0)
|
||||
: 0,
|
||||
latestReplySequence:
|
||||
message.parentMessageId === null
|
||||
? (metadata.get(message.id)?.latestReplySequence ?? null)
|
||||
: null,
|
||||
})),
|
||||
next: page.at(-1)?.sequence.toString() ?? input.after,
|
||||
through,
|
||||
hasMore,
|
||||
};
|
||||
},
|
||||
{ isolationLevel: "repeatable read" },
|
||||
);
|
||||
}
|
||||
|
||||
// Durable metadata-only mutation scan: fixed high-water survives pagination and reconnects.
|
||||
export async function chatMutationCatchUp(
|
||||
db: typeof defaultDb,
|
||||
raw: z.input<typeof mutationsSchema>,
|
||||
) {
|
||||
const input = mutationsSchema.parse(raw);
|
||||
return retryTransaction(
|
||||
db,
|
||||
async (tx) => {
|
||||
await tx.execute(sql`set transaction read only`);
|
||||
const channel = await authorize(tx, input);
|
||||
const highWater = channel.nextMutationSequence - 1n;
|
||||
if (
|
||||
BigInt(input.after) > highWater ||
|
||||
(input.through !== undefined && BigInt(input.through) > highWater)
|
||||
)
|
||||
throw new ChatReadError();
|
||||
const through = input.through ?? highWater.toString();
|
||||
const rows = await tx
|
||||
.select({
|
||||
messageId: chatMessageMutations.messageId,
|
||||
mutationSequence: chatMessageMutations.mutationSequence,
|
||||
revision: chatMessageMutations.revision,
|
||||
kind: chatMessageMutations.kind,
|
||||
})
|
||||
.from(chatMessageMutations)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessageMutations.workspaceId, input.workspaceId),
|
||||
eq(chatMessageMutations.channelId, input.channelId),
|
||||
gt(chatMessageMutations.mutationSequence, BigInt(input.after)),
|
||||
sql`${chatMessageMutations.mutationSequence} <= ${BigInt(through)}`,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(chatMessageMutations.mutationSequence))
|
||||
.limit(input.limit + 1);
|
||||
const page = rows.slice(0, input.limit);
|
||||
const messageIds = [...new Set(page.map((row) => row.messageId))];
|
||||
const messagesRows = messageIds.length ? await tx
|
||||
.select()
|
||||
.from(chatMessages)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessages.workspaceId, input.workspaceId),
|
||||
eq(chatMessages.channelId, input.channelId),
|
||||
inArray(chatMessages.id, messageIds),
|
||||
),
|
||||
) : [];
|
||||
const messagesMap = new Map(messagesRows.map((m) => [m.id, serialize(m)]));
|
||||
if (messagesMap.size !== messageIds.length) {
|
||||
throw new ChatReadError();
|
||||
}
|
||||
return {
|
||||
mutations: page.map((row) => ({
|
||||
messageId: row.messageId,
|
||||
mutationSequence: row.mutationSequence.toString(),
|
||||
revision: row.revision,
|
||||
kind: row.kind,
|
||||
})),
|
||||
messages: messageIds.map((id) => messagesMap.get(id)!),
|
||||
next: page.at(-1)?.mutationSequence.toString() ?? input.after,
|
||||
through,
|
||||
hasMore: rows.length > input.limit,
|
||||
};
|
||||
},
|
||||
{ isolationLevel: "repeatable read" },
|
||||
);
|
||||
}
|
||||
|
||||
// Mark-read validates that the claimed viewed sequence actually exists, then advances and emits
|
||||
// exactly one invalidation in the same transaction. A no-op never changes the cursor or emits.
|
||||
export async function chatMarkChannelRead(
|
||||
db: typeof defaultDb,
|
||||
raw: z.input<typeof markReadSchema>,
|
||||
) {
|
||||
const input = markReadSchema.parse(raw);
|
||||
return retryTransaction(
|
||||
db,
|
||||
async (tx) => {
|
||||
await authorize(tx, input);
|
||||
const [viewed] = await tx
|
||||
.select({ id: chatMessages.id })
|
||||
.from(chatMessages)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessages.workspaceId, input.workspaceId),
|
||||
eq(chatMessages.channelId, input.channelId),
|
||||
eq(chatMessages.sequence, BigInt(input.sequence)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!viewed) throw new ChatReadError();
|
||||
const [current] = await tx
|
||||
.select({ sequence: chatReadCursors.sequence })
|
||||
.from(chatReadCursors)
|
||||
.where(
|
||||
and(
|
||||
eq(chatReadCursors.workspaceId, input.workspaceId),
|
||||
eq(chatReadCursors.channelId, input.channelId),
|
||||
eq(chatReadCursors.userId, input.actorUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (current && current.sequence >= BigInt(input.sequence))
|
||||
return { sequence: current.sequence.toString(), changed: false };
|
||||
const [updated] = await tx
|
||||
.insert(chatReadCursors)
|
||||
.values({
|
||||
workspaceId: input.workspaceId,
|
||||
channelId: input.channelId,
|
||||
userId: input.actorUserId,
|
||||
sequence: BigInt(input.sequence),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
chatReadCursors.workspaceId,
|
||||
chatReadCursors.channelId,
|
||||
chatReadCursors.userId,
|
||||
],
|
||||
set: {
|
||||
sequence: sql`greatest(${chatReadCursors.sequence}, excluded.sequence)`,
|
||||
updatedAt: sql`now()`,
|
||||
},
|
||||
setWhere: lt(chatReadCursors.sequence, BigInt(input.sequence)),
|
||||
})
|
||||
.returning({ sequence: chatReadCursors.sequence });
|
||||
if (!updated)
|
||||
return {
|
||||
sequence: current?.sequence.toString() ?? "0",
|
||||
changed: false,
|
||||
};
|
||||
await tx.insert(chatOutbox).values({
|
||||
workspaceId: input.workspaceId,
|
||||
channelId: input.channelId,
|
||||
eventType: "chat.read_state.changed.v1",
|
||||
eventVersion: 1,
|
||||
dedupeKey: `read:${input.channelId}:${input.actorUserId}:${updated.sequence}`,
|
||||
payload: {
|
||||
eventType: "chat.read_state.changed.v1",
|
||||
eventVersion: 1,
|
||||
workspaceId: input.workspaceId,
|
||||
channelId: input.channelId,
|
||||
userId: input.actorUserId,
|
||||
sequence: updated.sequence.toString(),
|
||||
},
|
||||
});
|
||||
return { sequence: updated.sequence.toString(), changed: true };
|
||||
},
|
||||
{ isolationLevel: "repeatable read" },
|
||||
);
|
||||
}
|
||||
|
||||
// Retry the transaction as a unit so a failed attempt cannot commit a cursor without its outbox event.
|
||||
async function retryTransaction<T>(
|
||||
db: typeof defaultDb,
|
||||
callback: (tx: Tx) => Promise<T>,
|
||||
config: Parameters<typeof defaultDb.transaction>[1],
|
||||
): Promise<T> {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
return await db.transaction((tx) => callback(tx), config);
|
||||
} catch (error) {
|
||||
if (attempt === 2 || !isRetryableTransactionError(error)) throw error;
|
||||
}
|
||||
}
|
||||
throw new Error("Unreachable transaction retry state");
|
||||
}
|
||||
|
||||
function isRetryableTransactionError(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null) return false;
|
||||
const candidate = error as { code?: unknown; cause?: unknown };
|
||||
return (
|
||||
candidate.code === "40001" ||
|
||||
candidate.code === "40P01" ||
|
||||
(candidate.cause !== undefined &&
|
||||
isRetryableTransactionError(candidate.cause))
|
||||
);
|
||||
}
|
||||
|
||||
// Unread aggregation considers every authorized channel, including archived ones, but never
|
||||
// grants private/direct visibility based on the actor's workspace role alone.
|
||||
export async function chatUnreadSummary(
|
||||
db: typeof defaultDb,
|
||||
raw: z.input<typeof summarySchema>,
|
||||
) {
|
||||
const input = summarySchema.parse(raw);
|
||||
return db.transaction(
|
||||
async (tx) => {
|
||||
await tx.execute(sql`set transaction read only`);
|
||||
const [actor] = await tx
|
||||
.select({ userId: workspaceMembers.userId })
|
||||
.from(workspaceMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMembers.workspaceId, input.workspaceId),
|
||||
eq(workspaceMembers.userId, input.actorUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!actor) throw new ChatReadError();
|
||||
const channels = await tx
|
||||
.select({ channel: chatChannels })
|
||||
.from(chatChannels)
|
||||
.leftJoin(
|
||||
chatChannelMembers,
|
||||
and(
|
||||
eq(chatChannelMembers.workspaceId, chatChannels.workspaceId),
|
||||
eq(chatChannelMembers.channelId, chatChannels.id),
|
||||
eq(chatChannelMembers.userId, input.actorUserId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatChannels.workspaceId, input.workspaceId),
|
||||
sql`(${chatChannels.kind} = 'public' or ${chatChannelMembers.userId} is not null)`,
|
||||
),
|
||||
);
|
||||
// A single set-based count query keeps the summary bounded by channels, not messages.
|
||||
const counts = await tx
|
||||
.select({
|
||||
channelId: chatChannels.id,
|
||||
readSequence: sql<bigint>`coalesce(${chatReadCursors.sequence}, 0)`,
|
||||
unreadCount: sql<number>`count(${chatMessages.id})::int`,
|
||||
mentionCount: sql<number>`count(${chatMessageMentions.messageId})::int`,
|
||||
})
|
||||
.from(chatChannels)
|
||||
.leftJoin(
|
||||
chatReadCursors,
|
||||
and(
|
||||
eq(chatReadCursors.workspaceId, chatChannels.workspaceId),
|
||||
eq(chatReadCursors.channelId, chatChannels.id),
|
||||
eq(chatReadCursors.userId, input.actorUserId),
|
||||
),
|
||||
)
|
||||
.leftJoin(
|
||||
chatMessages,
|
||||
and(
|
||||
eq(chatMessages.workspaceId, chatChannels.workspaceId),
|
||||
eq(chatMessages.channelId, chatChannels.id),
|
||||
sql`${chatMessages.sequence} > coalesce(${chatReadCursors.sequence}, 0)`,
|
||||
),
|
||||
)
|
||||
.leftJoin(
|
||||
chatMessageMentions,
|
||||
and(
|
||||
eq(chatMessageMentions.workspaceId, chatMessages.workspaceId),
|
||||
eq(chatMessageMentions.messageId, chatMessages.id),
|
||||
eq(chatMessageMentions.userId, input.actorUserId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatChannels.workspaceId, input.workspaceId),
|
||||
inArray(
|
||||
chatChannels.id,
|
||||
channels.map(({ channel }) => channel.id),
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(chatChannels.id, chatReadCursors.sequence);
|
||||
const byId = new Map(counts.map((count) => [count.channelId, count]));
|
||||
return {
|
||||
channels: channels.map(({ channel }) => ({
|
||||
channelId: channel.id,
|
||||
archived: channel.archivedAt !== null,
|
||||
readSequence: byId.get(channel.id)?.readSequence.toString() ?? "0",
|
||||
unreadCount: byId.get(channel.id)?.unreadCount ?? 0,
|
||||
mentionCount: byId.get(channel.id)?.mentionCount ?? 0,
|
||||
})),
|
||||
};
|
||||
},
|
||||
{ isolationLevel: "repeatable read" },
|
||||
);
|
||||
}
|
||||
350
packages/database/src/chat/send-message.test.ts
Normal file
350
packages/database/src/chat/send-message.test.ts
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
sendChatMessage,
|
||||
ChatPersistenceError,
|
||||
type ChatPersistenceTransaction,
|
||||
} from "./send-message";
|
||||
|
||||
const workspaceId = "11111111-1111-4111-8111-111111111111";
|
||||
const channelId = "22222222-2222-4222-8222-222222222222";
|
||||
const userId = "33333333-3333-4333-8333-333333333333";
|
||||
const mentionId = "44444444-4444-4444-8444-444444444444";
|
||||
const parentId = "55555555-5555-4555-8555-555555555555";
|
||||
|
||||
function command(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "send_message",
|
||||
workspaceId,
|
||||
channelId,
|
||||
context: { type: "workspace", workspaceId },
|
||||
body: "hello",
|
||||
clientIdempotencyKey: "client-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function harness(
|
||||
options: {
|
||||
kind?: "public" | "private" | "direct";
|
||||
archived?: boolean;
|
||||
channelMember?: boolean;
|
||||
workspaceMember?: boolean;
|
||||
role?: "owner" | "admin" | "member" | "guest";
|
||||
factsMissing?: boolean;
|
||||
factsWorkspaceId?: string;
|
||||
factsChannelId?: string;
|
||||
existing?: boolean;
|
||||
persistedMentions?: string[];
|
||||
pinnedFingerprint?: boolean;
|
||||
changedBody?: boolean;
|
||||
parent?: {
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
parentMessageId: string | null;
|
||||
};
|
||||
validMentions?: string[];
|
||||
} = {},
|
||||
) {
|
||||
const calls: Array<[string, unknown]> = [];
|
||||
const message = {
|
||||
id: "66666666-6666-4666-8666-666666666666",
|
||||
workspaceId,
|
||||
channelId,
|
||||
sequence: 7n,
|
||||
parentMessageId: null,
|
||||
authorUserId: userId,
|
||||
clientIdempotencyKey: "client-1",
|
||||
body: options.changedBody ? "edited later" : "hello",
|
||||
sendIntentFingerprint: options.pinnedFingerprint
|
||||
? createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify([workspaceId, channelId, userId, "hello", null, []]),
|
||||
)
|
||||
.digest("hex")
|
||||
: null,
|
||||
};
|
||||
const tx: ChatPersistenceTransaction = {
|
||||
claimIdempotency: async (scope) => {
|
||||
calls.push(["idempotency", scope]);
|
||||
return options.existing ? message : null;
|
||||
},
|
||||
loadMessageMentions: async (scope) => {
|
||||
calls.push(["existing-mentions", scope]);
|
||||
return options.persistedMentions ?? [];
|
||||
},
|
||||
loadSendFacts: async (scope) => {
|
||||
calls.push(["facts", scope]);
|
||||
if (options.factsMissing) return null;
|
||||
return {
|
||||
actor: {
|
||||
userId: scope.actorUserId,
|
||||
workspaceId: options.factsWorkspaceId ?? workspaceId,
|
||||
activeWorkspaceMember: options.workspaceMember ?? true,
|
||||
role: options.role ?? "member",
|
||||
},
|
||||
channel: {
|
||||
id: options.factsChannelId ?? channelId,
|
||||
workspaceId: options.factsWorkspaceId ?? workspaceId,
|
||||
kind: options.kind ?? "public",
|
||||
archived: options.archived ?? false,
|
||||
activeMember: options.channelMember ?? false,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
};
|
||||
},
|
||||
loadParent: async (scope) => {
|
||||
calls.push(["parent", scope]);
|
||||
return (
|
||||
options.parent ?? { workspaceId, channelId, parentMessageId: null }
|
||||
);
|
||||
},
|
||||
validateMentionMembers: async (scope) => {
|
||||
calls.push(["mentions-valid", scope]);
|
||||
return options.validMentions ?? scope.userIds;
|
||||
},
|
||||
allocateSequence: async (scope) => {
|
||||
calls.push(["sequence", scope]);
|
||||
return 7n;
|
||||
},
|
||||
insertMessage: async (values) => {
|
||||
calls.push(["message", values]);
|
||||
return values;
|
||||
},
|
||||
insertMentions: async (values) => {
|
||||
calls.push(["mentions", values]);
|
||||
},
|
||||
insertOutbox: async (values) => {
|
||||
calls.push(["outbox", values]);
|
||||
},
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
message,
|
||||
run: (
|
||||
input: unknown = command(),
|
||||
mentionedUserIds: string[] = [],
|
||||
actorUserId = userId,
|
||||
) =>
|
||||
sendChatMessage(
|
||||
{ transaction: (fn) => fn(tx) },
|
||||
{ actorUserId, command: input, mentionedUserIds },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("sendChatMessage", () => {
|
||||
it("orchestrates message, validated mentions, and one deterministic outbox event through the transaction seam", async () => {
|
||||
const h = harness();
|
||||
const result = await h.run(command(), [mentionId, mentionId]);
|
||||
expect(result.created).toBe(true);
|
||||
expect(h.calls.map(([name]) => name)).toEqual([
|
||||
"idempotency",
|
||||
"facts",
|
||||
"mentions-valid",
|
||||
"sequence",
|
||||
"message",
|
||||
"mentions",
|
||||
"outbox",
|
||||
]);
|
||||
expect(h.calls.find(([name]) => name === "outbox")?.[1]).toMatchObject({
|
||||
workspaceId,
|
||||
messageId: result.message.id,
|
||||
eventVersion: 1,
|
||||
eventType: "chat.message.created.v1",
|
||||
dedupeKey: `message:${result.message.id}:created:v1`,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an idempotent retry before allocating sequence or writing outbox", async () => {
|
||||
const h = harness({ existing: true });
|
||||
expect((await h.run()).created).toBe(false);
|
||||
expect(h.calls.map(([name]) => name)).toEqual([
|
||||
"idempotency",
|
||||
"facts",
|
||||
"existing-mentions",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
reason: "removed workspace membership",
|
||||
options: { workspaceMember: false },
|
||||
},
|
||||
{ reason: "missing current facts", options: { factsMissing: true } },
|
||||
{
|
||||
reason: "different workspace facts",
|
||||
options: { factsWorkspaceId: "99999999-9999-4999-8999-999999999999" },
|
||||
},
|
||||
{
|
||||
reason: "different channel facts",
|
||||
options: { factsChannelId: "99999999-9999-4999-8999-999999999999" },
|
||||
},
|
||||
{
|
||||
reason: "revoked private admin membership",
|
||||
options: {
|
||||
kind: "private" as const,
|
||||
role: "admin" as const,
|
||||
channelMember: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
reason: "revoked direct admin membership",
|
||||
options: {
|
||||
kind: "direct" as const,
|
||||
role: "admin" as const,
|
||||
channelMember: false,
|
||||
},
|
||||
},
|
||||
])(
|
||||
"rejects a replay after $reason without leaking message contents",
|
||||
async ({ options }) => {
|
||||
const h = harness({ existing: true, ...options });
|
||||
await expect(h.run()).rejects.toMatchObject({ code: "CHAT_FORBIDDEN" });
|
||||
expect(h.calls.map(([name]) => name)).toEqual(["idempotency", "facts"]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["private", "direct"] as const)(
|
||||
"allows a visible %s channel replay without creating another message",
|
||||
async (kind) => {
|
||||
const h = harness({ existing: true, kind, channelMember: true });
|
||||
expect(await h.run()).toMatchObject({
|
||||
created: false,
|
||||
message: { id: h.message.id },
|
||||
});
|
||||
expect(h.calls.map(([name]) => name)).toEqual([
|
||||
"idempotency",
|
||||
"facts",
|
||||
"existing-mentions",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("allows replay in an archived channel if it remains readable, but not a new send", async () => {
|
||||
const retry = harness({
|
||||
existing: true,
|
||||
archived: true,
|
||||
kind: "private",
|
||||
channelMember: true,
|
||||
});
|
||||
expect(await retry.run()).toMatchObject({
|
||||
created: false,
|
||||
message: { id: retry.message.id },
|
||||
});
|
||||
expect(retry.calls.map(([name]) => name)).toEqual([
|
||||
"idempotency",
|
||||
"facts",
|
||||
"existing-mentions",
|
||||
]);
|
||||
|
||||
const fresh = harness({ archived: true });
|
||||
await expect(fresh.run()).rejects.toMatchObject({ code: "CHAT_FORBIDDEN" });
|
||||
expect(fresh.calls.map(([name]) => name)).toEqual(["idempotency", "facts"]);
|
||||
});
|
||||
|
||||
it("treats duplicate and reordered requested mentions as the same intent", async () => {
|
||||
const secondMentionId = "88888888-8888-4888-8888-888888888888";
|
||||
const h = harness({
|
||||
existing: true,
|
||||
persistedMentions: [mentionId, secondMentionId],
|
||||
});
|
||||
expect(
|
||||
(await h.run(command(), [secondMentionId, mentionId, secondMentionId]))
|
||||
.created,
|
||||
).toBe(false);
|
||||
expect(h.calls[2]).toEqual([
|
||||
"existing-mentions",
|
||||
{ workspaceId, channelId, messageId: h.message.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ persisted: [mentionId], requested: [] },
|
||||
{ persisted: [], requested: [mentionId] },
|
||||
{
|
||||
persisted: [mentionId],
|
||||
requested: ["88888888-8888-4888-8888-888888888888"],
|
||||
},
|
||||
])(
|
||||
"rejects changed mention intent without allocating sequence or writing",
|
||||
async ({ persisted, requested }) => {
|
||||
const h = harness({ existing: true, persistedMentions: persisted });
|
||||
await expect(h.run(command(), requested)).rejects.toMatchObject({
|
||||
code: "CHAT_IDEMPOTENCY_CONFLICT",
|
||||
});
|
||||
expect(h.calls.map(([name]) => name)).toEqual([
|
||||
"idempotency",
|
||||
"facts",
|
||||
"existing-mentions",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("matches immutable send intent after a subsequent edit without exposing the fingerprint", async () => {
|
||||
const h = harness({
|
||||
existing: true,
|
||||
pinnedFingerprint: true,
|
||||
changedBody: true,
|
||||
});
|
||||
const replay = await h.run();
|
||||
expect(replay.created).toBe(false);
|
||||
expect(replay.message).not.toHaveProperty("sendIntentFingerprint");
|
||||
await expect(
|
||||
h.run(command({ body: "edited later" })),
|
||||
).rejects.toMatchObject({ code: "CHAT_IDEMPOTENCY_CONFLICT" });
|
||||
});
|
||||
|
||||
it("rejects a channel-key collision from another actor before allocating sequence", async () => {
|
||||
const h = harness({ existing: true });
|
||||
await expect(
|
||||
h.run(command(), [], "77777777-7777-4777-8777-777777777777"),
|
||||
).rejects.toMatchObject({ code: "CHAT_IDEMPOTENCY_CONFLICT" });
|
||||
expect(h.calls.map(([name]) => name)).toEqual(["idempotency", "facts"]);
|
||||
});
|
||||
|
||||
it("rejects archived, nonmember, and unauthorized private channel writes", async () => {
|
||||
for (const options of [
|
||||
{ archived: true },
|
||||
{ workspaceMember: false },
|
||||
{ kind: "private" as const, channelMember: false },
|
||||
]) {
|
||||
const h = harness(options);
|
||||
await expect(h.run()).rejects.toBeInstanceOf(ChatPersistenceError);
|
||||
expect(h.calls.map(([name]) => name)).toEqual(["idempotency", "facts"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires replies to target a root in the same tenant and channel", async () => {
|
||||
const h = harness({
|
||||
parent: { workspaceId, channelId, parentMessageId: parentId },
|
||||
});
|
||||
await expect(
|
||||
h.run(command({ parentMessageId: parentId })),
|
||||
).rejects.toMatchObject({ code: "CHAT_INVALID_PARENT" });
|
||||
expect(h.calls[2]?.[1]).toEqual({
|
||||
workspaceId,
|
||||
channelId,
|
||||
messageId: parentId,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects mentions that are not active members of this workspace", async () => {
|
||||
const h = harness({ validMentions: [] });
|
||||
await expect(h.run(command(), [mentionId])).rejects.toMatchObject({
|
||||
code: "CHAT_INVALID_MENTION",
|
||||
});
|
||||
expect(h.calls[2]?.[1]).toEqual({ workspaceId, userIds: [mentionId] });
|
||||
});
|
||||
|
||||
it("parses the accepted command contract and rejects mismatched context", async () => {
|
||||
const h = harness();
|
||||
await expect(
|
||||
h.run(
|
||||
command({ context: { type: "workspace", workspaceId: mentionId } }),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(h.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
304
packages/database/src/chat/send-message.ts
Normal file
304
packages/database/src/chat/send-message.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
canPerformChatAction,
|
||||
chatCommandSchema,
|
||||
chatMessageCreatedV1Schema,
|
||||
type ChatCommand,
|
||||
type ChatPolicyInput,
|
||||
} from "@tasks/shared";
|
||||
import { z } from "zod";
|
||||
|
||||
const uuidSchema = z.string().uuid();
|
||||
const mentionIdsSchema = z.array(uuidSchema).max(100);
|
||||
const sendMessageCommandSchema = chatCommandSchema.refine(
|
||||
(command): command is Extract<ChatCommand, { type: "send_message" }> =>
|
||||
command.type === "send_message",
|
||||
{ message: "Expected a send_message command" },
|
||||
);
|
||||
|
||||
type SendCommand = z.infer<typeof sendMessageCommandSchema>;
|
||||
type SendFacts = Pick<
|
||||
Extract<ChatPolicyInput, { action: "send" }>,
|
||||
"actor" | "channel"
|
||||
>;
|
||||
|
||||
export type PersistedChatMessage = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
sequence: bigint;
|
||||
body: string;
|
||||
parentMessageId: string | null;
|
||||
authorUserId: string;
|
||||
clientIdempotencyKey: string;
|
||||
sendIntentFingerprint?: string | null;
|
||||
status?: "sent" | "edited" | "deleted";
|
||||
};
|
||||
|
||||
type IdempotencyScope = {
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
clientIdempotencyKey: string;
|
||||
};
|
||||
|
||||
export type ChatPersistenceTransaction = {
|
||||
/**
|
||||
* Serialize this channel-scoped key for the transaction, then return its
|
||||
* message if already committed. A Postgres adapter can take a transaction-
|
||||
* scoped advisory lock derived from all three fields before selecting.
|
||||
*/
|
||||
claimIdempotency(
|
||||
scope: IdempotencyScope,
|
||||
): Promise<PersistedChatMessage | null>;
|
||||
loadMessageMentions(scope: {
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
}): Promise<string[]>;
|
||||
loadSendFacts(scope: {
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
actorUserId: string;
|
||||
}): Promise<SendFacts | null>;
|
||||
loadParent(scope: {
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
}): Promise<{
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
parentMessageId: string | null;
|
||||
} | null>;
|
||||
validateMentionMembers(scope: {
|
||||
workspaceId: string;
|
||||
userIds: string[];
|
||||
}): Promise<string[]>;
|
||||
allocateSequence(scope: {
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
}): Promise<bigint>;
|
||||
insertMessage(values: PersistedChatMessage): Promise<PersistedChatMessage>;
|
||||
insertMentions(values: {
|
||||
workspaceId: string;
|
||||
messageId: string;
|
||||
userIds: string[];
|
||||
}): Promise<void>;
|
||||
insertOutbox(values: {
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
eventVersion: 1;
|
||||
eventType: "chat.message.created.v1";
|
||||
dedupeKey: string;
|
||||
payload: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
};
|
||||
|
||||
export type ChatTransactionRunner = {
|
||||
/** Rolls back sequence, message, mention, and outbox writes if run rejects. */
|
||||
transaction<T>(
|
||||
run: (tx: ChatPersistenceTransaction) => Promise<T>,
|
||||
): Promise<T>;
|
||||
};
|
||||
|
||||
export type ChatPersistenceErrorCode =
|
||||
| "CHAT_FORBIDDEN"
|
||||
| "CHAT_INVALID_PARENT"
|
||||
| "CHAT_INVALID_MENTION"
|
||||
| "CHAT_IDEMPOTENCY_CONFLICT";
|
||||
|
||||
export class ChatPersistenceError extends Error {
|
||||
constructor(readonly code: ChatPersistenceErrorCode) {
|
||||
super(code);
|
||||
this.name = "ChatPersistenceError";
|
||||
}
|
||||
}
|
||||
|
||||
function publicMessage(message: PersistedChatMessage): PersistedChatMessage {
|
||||
return {
|
||||
id: message.id,
|
||||
workspaceId: message.workspaceId,
|
||||
channelId: message.channelId,
|
||||
sequence: message.sequence,
|
||||
body: message.body,
|
||||
parentMessageId: message.parentMessageId,
|
||||
authorUserId: message.authorUserId,
|
||||
clientIdempotencyKey: message.clientIdempotencyKey,
|
||||
};
|
||||
}
|
||||
|
||||
function sendIntentFingerprint(
|
||||
command: SendCommand,
|
||||
actorUserId: string,
|
||||
mentionedUserIds: string[],
|
||||
): string {
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify([
|
||||
command.workspaceId,
|
||||
command.channelId,
|
||||
actorUserId,
|
||||
command.body,
|
||||
command.parentMessageId ?? null,
|
||||
mentionedUserIds,
|
||||
]),
|
||||
)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function matchesIntent(
|
||||
message: PersistedChatMessage,
|
||||
command: SendCommand,
|
||||
actorUserId: string,
|
||||
mentionedUserIds: string[],
|
||||
): boolean {
|
||||
return (
|
||||
message.workspaceId === command.workspaceId &&
|
||||
message.channelId === command.channelId &&
|
||||
message.clientIdempotencyKey === command.clientIdempotencyKey &&
|
||||
message.authorUserId === actorUserId &&
|
||||
(message.sendIntentFingerprint
|
||||
? message.sendIntentFingerprint ===
|
||||
sendIntentFingerprint(command, actorUserId, mentionedUserIds)
|
||||
: message.status !== "edited" && message.status !== "deleted" &&
|
||||
message.body === command.body &&
|
||||
message.parentMessageId === (command.parentMessageId ?? null))
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendChatMessage(
|
||||
runner: ChatTransactionRunner,
|
||||
input: {
|
||||
actorUserId: string;
|
||||
command: unknown;
|
||||
mentionedUserIds?: string[];
|
||||
},
|
||||
): Promise<{ created: boolean; message: PersistedChatMessage }> {
|
||||
const command = sendMessageCommandSchema.parse(input.command);
|
||||
const actorUserId = uuidSchema.parse(input.actorUserId);
|
||||
const mentionIds = [
|
||||
...new Set(mentionIdsSchema.parse(input.mentionedUserIds ?? [])),
|
||||
].sort();
|
||||
|
||||
return runner.transaction(async (tx) => {
|
||||
const existing = await tx.claimIdempotency({
|
||||
workspaceId: command.workspaceId,
|
||||
channelId: command.channelId,
|
||||
clientIdempotencyKey: command.clientIdempotencyKey,
|
||||
});
|
||||
const facts = await tx.loadSendFacts({
|
||||
workspaceId: command.workspaceId,
|
||||
channelId: command.channelId,
|
||||
actorUserId,
|
||||
});
|
||||
if (
|
||||
!facts ||
|
||||
facts.channel.id !== command.channelId ||
|
||||
!canPerformChatAction({
|
||||
action: existing ? "read" : "send",
|
||||
actor: facts.actor,
|
||||
channel: facts.channel,
|
||||
context: command.context,
|
||||
})
|
||||
)
|
||||
throw new ChatPersistenceError("CHAT_FORBIDDEN");
|
||||
|
||||
if (existing) {
|
||||
if (!matchesIntent(existing, command, actorUserId, mentionIds))
|
||||
throw new ChatPersistenceError("CHAT_IDEMPOTENCY_CONFLICT");
|
||||
const persistedMentionIds = [
|
||||
...new Set(
|
||||
await tx.loadMessageMentions({
|
||||
workspaceId: command.workspaceId,
|
||||
channelId: command.channelId,
|
||||
messageId: existing.id,
|
||||
}),
|
||||
),
|
||||
].sort();
|
||||
if (!existing.sendIntentFingerprint && existing.body !== command.body)
|
||||
throw new ChatPersistenceError("CHAT_IDEMPOTENCY_CONFLICT");
|
||||
if (
|
||||
persistedMentionIds.length !== mentionIds.length ||
|
||||
persistedMentionIds.some((id, index) => id !== mentionIds[index])
|
||||
)
|
||||
throw new ChatPersistenceError("CHAT_IDEMPOTENCY_CONFLICT");
|
||||
return { created: false, message: publicMessage(existing) };
|
||||
}
|
||||
|
||||
if (command.parentMessageId) {
|
||||
const parent = await tx.loadParent({
|
||||
workspaceId: command.workspaceId,
|
||||
channelId: command.channelId,
|
||||
messageId: command.parentMessageId,
|
||||
});
|
||||
if (
|
||||
!parent ||
|
||||
parent.workspaceId !== command.workspaceId ||
|
||||
parent.channelId !== command.channelId ||
|
||||
parent.parentMessageId !== null
|
||||
)
|
||||
throw new ChatPersistenceError("CHAT_INVALID_PARENT");
|
||||
}
|
||||
|
||||
const validMentionIds = await tx.validateMentionMembers({
|
||||
workspaceId: command.workspaceId,
|
||||
userIds: mentionIds,
|
||||
});
|
||||
if (
|
||||
validMentionIds.length !== mentionIds.length ||
|
||||
mentionIds.some((id) => !validMentionIds.includes(id))
|
||||
)
|
||||
throw new ChatPersistenceError("CHAT_INVALID_MENTION");
|
||||
|
||||
const sequence = await tx.allocateSequence({
|
||||
workspaceId: command.workspaceId,
|
||||
channelId: command.channelId,
|
||||
});
|
||||
const inserted = await tx.insertMessage({
|
||||
id: crypto.randomUUID(),
|
||||
workspaceId: command.workspaceId,
|
||||
channelId: command.channelId,
|
||||
sequence,
|
||||
body: command.body,
|
||||
parentMessageId: command.parentMessageId ?? null,
|
||||
authorUserId: actorUserId,
|
||||
clientIdempotencyKey: command.clientIdempotencyKey,
|
||||
sendIntentFingerprint: sendIntentFingerprint(
|
||||
command,
|
||||
actorUserId,
|
||||
mentionIds,
|
||||
),
|
||||
});
|
||||
|
||||
if (mentionIds.length)
|
||||
await tx.insertMentions({
|
||||
workspaceId: command.workspaceId,
|
||||
messageId: inserted.id,
|
||||
userIds: mentionIds,
|
||||
});
|
||||
|
||||
const event = chatMessageCreatedV1Schema.parse({
|
||||
eventType: "chat.message.created.v1",
|
||||
eventVersion: 1,
|
||||
workspaceId: command.workspaceId,
|
||||
channelId: command.channelId,
|
||||
messageId: inserted.id,
|
||||
sequence: inserted.sequence.toString(),
|
||||
parentMessageId: inserted.parentMessageId,
|
||||
author: { type: "human", userId: actorUserId },
|
||||
body: inserted.body,
|
||||
mentionedUserIds: mentionIds,
|
||||
clientIdempotencyKey: command.clientIdempotencyKey,
|
||||
});
|
||||
await tx.insertOutbox({
|
||||
workspaceId: command.workspaceId,
|
||||
channelId: command.channelId,
|
||||
messageId: inserted.id,
|
||||
eventVersion: event.eventVersion,
|
||||
eventType: event.eventType,
|
||||
dedupeKey: `message:${inserted.id}:created:v1`,
|
||||
payload: event,
|
||||
});
|
||||
return { created: true, message: publicMessage(inserted) };
|
||||
});
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export * from "./schema";
|
||||
export * from "./chat";
|
||||
export { db } from "./client";
|
||||
|
|
|
|||
25
packages/database/src/schema/chat-migration.test.ts
Normal file
25
packages/database/src/schema/chat-migration.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
const migration = (name: string) =>
|
||||
readFileSync(new URL(`../../migrations/${name}`, import.meta.url), "utf8");
|
||||
describe("chat lifecycle migrations", () => {
|
||||
it("backfills channel outbox identity before making it required", () => {
|
||||
const sql = migration("0020_demonic_hammerhead.sql");
|
||||
expect(sql).toContain('ADD COLUMN "channel_id" uuid;');
|
||||
expect(sql).toContain('UPDATE "chat_outbox"');
|
||||
expect(sql).toContain('ALTER COLUMN "channel_id" SET NOT NULL');
|
||||
expect(sql).toContain('CREATE TABLE "chat_mutation_receipts"');
|
||||
});
|
||||
it("validates staging transitions and makes direct identity immutable without a caller bypass", () => {
|
||||
const sql = migration("0021_chat_direct_membership_immutability.sql");
|
||||
expect(sql).toContain(
|
||||
"md5(string_agg(m.user_id::text, ':' ORDER BY m.user_id::text))",
|
||||
);
|
||||
expect(sql).toContain("member_count < 2");
|
||||
expect(sql).toContain("direct channel membership is immutable");
|
||||
expect(sql).toContain("SET search_path = pg_catalog, public");
|
||||
expect(sql).toContain("REVOKE ALL ON FUNCTION");
|
||||
expect(sql).not.toContain("current_setting");
|
||||
expect(sql).not.toContain("SECURITY DEFINER");
|
||||
});
|
||||
});
|
||||
220
packages/database/src/schema/chat.test.ts
Normal file
220
packages/database/src/schema/chat.test.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { SQL } from "drizzle-orm";
|
||||
import { getTableConfig } from "drizzle-orm/pg-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
chatChannelMembers,
|
||||
chatChannels,
|
||||
chatMessageMentions,
|
||||
chatMessages,
|
||||
chatOutbox,
|
||||
chatReadCursors,
|
||||
} from "./chat";
|
||||
|
||||
const config = (table: Parameters<typeof getTableConfig>[0]) =>
|
||||
getTableConfig(table);
|
||||
const columnNames = (columns: { name: string }[]) =>
|
||||
columns.map(({ name }) => name);
|
||||
|
||||
const foreignKey = (
|
||||
table: Parameters<typeof getTableConfig>[0],
|
||||
name: string,
|
||||
) => {
|
||||
const key = config(table).foreignKeys.find((item) => item.getName() === name);
|
||||
expect(key, `missing foreign key ${name}`).toBeDefined();
|
||||
return key!;
|
||||
};
|
||||
|
||||
const expectForeignKey = (
|
||||
table: Parameters<typeof getTableConfig>[0],
|
||||
name: string,
|
||||
columns: string[],
|
||||
foreignColumns: string[],
|
||||
onDelete: string,
|
||||
) => {
|
||||
const key = foreignKey(table, name);
|
||||
const reference = key.reference();
|
||||
expect(columnNames(reference.columns)).toEqual(columns);
|
||||
expect(columnNames(reference.foreignColumns)).toEqual(foreignColumns);
|
||||
expect(key.onDelete).toBe(onDelete);
|
||||
};
|
||||
|
||||
const uniqueColumns = (
|
||||
table: Parameters<typeof getTableConfig>[0],
|
||||
name: string,
|
||||
) => {
|
||||
const constraint = config(table).uniqueConstraints.find(
|
||||
(item) => item.name === name,
|
||||
);
|
||||
expect(constraint, `missing unique constraint ${name}`).toBeDefined();
|
||||
return columnNames(constraint!.columns);
|
||||
};
|
||||
|
||||
describe("chat persistence schema", () => {
|
||||
it.each([
|
||||
chatChannels,
|
||||
chatChannelMembers,
|
||||
chatMessages,
|
||||
chatMessageMentions,
|
||||
chatReadCursors,
|
||||
chatOutbox,
|
||||
])("requires and indexes the workspace tenant key", (table) => {
|
||||
expect(table.workspaceId.notNull).toBe(true);
|
||||
const workspaceIndex = config(table).indexes.find((item) =>
|
||||
item.config.name?.endsWith("workspace_id_idx"),
|
||||
);
|
||||
expect(workspaceIndex).toBeDefined();
|
||||
expect(
|
||||
columnNames(workspaceIndex!.config.columns as { name: string }[]),
|
||||
).toEqual(["workspace_id"]);
|
||||
});
|
||||
|
||||
it("cascades tenant deletion through each chat aggregate root", () => {
|
||||
for (const table of [
|
||||
chatChannels,
|
||||
chatChannelMembers,
|
||||
chatMessages,
|
||||
chatMessageMentions,
|
||||
chatReadCursors,
|
||||
chatOutbox,
|
||||
]) {
|
||||
const key = config(table).foreignKeys.find(
|
||||
(item) =>
|
||||
item.reference().foreignColumns[0]?.name === "id" &&
|
||||
item.reference().foreignTable ===
|
||||
config(chatChannels).foreignKeys[0]?.reference().foreignTable,
|
||||
);
|
||||
expect(key?.onDelete).toBe("cascade");
|
||||
}
|
||||
});
|
||||
|
||||
it("anchors channel owners and channel members in the same workspace membership", () => {
|
||||
expectForeignKey(
|
||||
chatChannels,
|
||||
"chat_channels_owner_member_fk",
|
||||
["workspace_id", "owner_user_id"],
|
||||
["workspace_id", "user_id"],
|
||||
"no action",
|
||||
);
|
||||
expectForeignKey(
|
||||
chatChannelMembers,
|
||||
"chat_channel_members_workspace_member_fk",
|
||||
["workspace_id", "user_id"],
|
||||
["workspace_id", "user_id"],
|
||||
"no action",
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes archived_at as the structural signal for transaction-enforced read-only channels", () => {
|
||||
expect(chatChannels.archivedAt.name).toBe("archived_at");
|
||||
expect(chatChannels.archivedAt.notNull).toBe(false);
|
||||
expect(config(chatChannels).checks.map((item) => item.name)).not.toContain(
|
||||
"chat_channels_archived_read_only_check",
|
||||
);
|
||||
});
|
||||
|
||||
it("supports canonical immutable direct identity for transaction-layer enforcement", () => {
|
||||
const names = config(chatChannels).checks.map((item) => item.name);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
"chat_channels_kind_check",
|
||||
"chat_channels_direct_key_check",
|
||||
]),
|
||||
);
|
||||
const directIndex = config(chatChannels).indexes.find(
|
||||
(item) =>
|
||||
item.config.name === "chat_channels_workspace_id_direct_key_unique",
|
||||
);
|
||||
expect(directIndex?.config.unique).toBe(true);
|
||||
expect(
|
||||
columnNames(directIndex!.config.columns as { name: string }[]),
|
||||
).toEqual(["workspace_id", "direct_key"]);
|
||||
});
|
||||
|
||||
it("keeps parent messages in the same workspace and channel without nulling identity", () => {
|
||||
expect(
|
||||
uniqueColumns(chatMessages, "chat_messages_workspace_channel_id_unique"),
|
||||
).toEqual(["workspace_id", "channel_id", "id"]);
|
||||
expectForeignKey(
|
||||
chatMessages,
|
||||
"chat_messages_parent_fk",
|
||||
["workspace_id", "channel_id", "parent_message_id"],
|
||||
["workspace_id", "channel_id", "id"],
|
||||
"no action",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the exact author union and durable human membership identity", () => {
|
||||
expect(config(chatMessages).checks.map((item) => item.name)).toContain(
|
||||
"chat_messages_author_check",
|
||||
);
|
||||
expectForeignKey(
|
||||
chatMessages,
|
||||
"chat_messages_human_author_member_fk",
|
||||
["workspace_id", "author_user_id"],
|
||||
["workspace_id", "user_id"],
|
||||
"no action",
|
||||
);
|
||||
expect(
|
||||
config(chatMessages).foreignKeys.some((item) =>
|
||||
item.reference().columns.includes(chatMessages.authorAgentId),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("anchors mentions and read cursors in workspace membership", () => {
|
||||
expectForeignKey(
|
||||
chatMessageMentions,
|
||||
"chat_message_mentions_workspace_member_fk",
|
||||
["workspace_id", "user_id"],
|
||||
["workspace_id", "user_id"],
|
||||
"no action",
|
||||
);
|
||||
expectForeignKey(
|
||||
chatReadCursors,
|
||||
"chat_read_cursors_workspace_member_fk",
|
||||
["workspace_id", "user_id"],
|
||||
["workspace_id", "user_id"],
|
||||
"no action",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses bigint-backed channel and cursor sequences", () => {
|
||||
expect(chatChannels.nextSequence.columnType).toBe("PgBigInt64");
|
||||
expect(chatMessages.sequence.columnType).toBe("PgBigInt64");
|
||||
expect(chatReadCursors.sequence.columnType).toBe("PgBigInt64");
|
||||
|
||||
const bigintColumns = [
|
||||
chatChannels.nextSequence,
|
||||
chatMessages.sequence,
|
||||
chatReadCursors.sequence,
|
||||
];
|
||||
for (const column of bigintColumns) {
|
||||
if (column.hasDefault) expect(column.default).toBeInstanceOf(SQL);
|
||||
}
|
||||
expect(config(chatChannels).checks.map((item) => item.name)).toContain(
|
||||
"chat_channels_next_sequence_check",
|
||||
);
|
||||
expect(config(chatMessages).checks.map((item) => item.name)).toContain(
|
||||
"chat_messages_sequence_check",
|
||||
);
|
||||
});
|
||||
|
||||
it("declares sequence, idempotency, and outbox dedupe uniqueness by tenant scope", () => {
|
||||
expect(
|
||||
uniqueColumns(
|
||||
chatMessages,
|
||||
"chat_messages_workspace_channel_sequence_unique",
|
||||
),
|
||||
).toEqual(["workspace_id", "channel_id", "sequence"]);
|
||||
expect(
|
||||
uniqueColumns(
|
||||
chatMessages,
|
||||
"chat_messages_workspace_channel_client_key_unique",
|
||||
),
|
||||
).toEqual(["workspace_id", "channel_id", "client_idempotency_key"]);
|
||||
expect(
|
||||
uniqueColumns(chatOutbox, "chat_outbox_workspace_id_dedupe_key_unique"),
|
||||
).toEqual(["workspace_id", "dedupe_key"]);
|
||||
});
|
||||
});
|
||||
387
packages/database/src/schema/chat.ts
Normal file
387
packages/database/src/schema/chat.ts
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
check,
|
||||
foreignKey,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
unique,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { workspaceMembers } from "./objects";
|
||||
import { workspaces } from "./workspaces";
|
||||
|
||||
export const chatChannels = pgTable(
|
||||
"chat_channels",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
workspaceId: uuid("workspace_id")
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
kind: varchar("kind", { length: 16 })
|
||||
.notNull()
|
||||
.$type<"public" | "private" | "direct">(),
|
||||
name: varchar("name", { length: 200 }),
|
||||
ownerUserId: uuid("owner_user_id").notNull(),
|
||||
directKey: text("direct_key"),
|
||||
nextSequence: bigint("next_sequence", { mode: "bigint" })
|
||||
.notNull()
|
||||
.default(sql`1`),
|
||||
nextMutationSequence: bigint("next_mutation_sequence", { mode: "bigint" })
|
||||
.notNull()
|
||||
.default(sql`1`),
|
||||
/** Structural signal; transaction policy must reject writes when this is non-null. */
|
||||
archivedAt: timestamp("archived_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
workspaceIdx: index("chat_channels_workspace_id_idx").on(table.workspaceId),
|
||||
workspaceIdUnique: unique("chat_channels_workspace_id_id_unique").on(
|
||||
table.workspaceId,
|
||||
table.id,
|
||||
),
|
||||
ownerFk: foreignKey({
|
||||
columns: [table.workspaceId, table.ownerUserId],
|
||||
foreignColumns: [workspaceMembers.workspaceId, workspaceMembers.userId],
|
||||
name: "chat_channels_owner_member_fk",
|
||||
}),
|
||||
directKeyUnique: uniqueIndex("chat_channels_workspace_id_direct_key_unique")
|
||||
.on(table.workspaceId, table.directKey)
|
||||
.where(sql`${table.directKey} is not null`),
|
||||
kindCheck: check(
|
||||
"chat_channels_kind_check",
|
||||
sql`${table.kind} in ('public', 'private', 'direct')`,
|
||||
),
|
||||
directKeyCheck: check(
|
||||
"chat_channels_direct_key_check",
|
||||
sql`(${table.kind} = 'direct' and ${table.directKey} is not null and ${table.name} is null) or (${table.kind} <> 'direct' and ${table.directKey} is null)`,
|
||||
),
|
||||
sequenceCheck: check(
|
||||
"chat_channels_next_sequence_check",
|
||||
sql`${table.nextSequence} > 0`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const chatChannelMembers = pgTable(
|
||||
"chat_channel_members",
|
||||
{
|
||||
workspaceId: uuid("workspace_id")
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
channelId: uuid("channel_id").notNull(),
|
||||
userId: uuid("user_id").notNull(),
|
||||
joinedAt: timestamp("joined_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({
|
||||
columns: [table.workspaceId, table.channelId, table.userId],
|
||||
}),
|
||||
workspaceIdx: index("chat_channel_members_workspace_id_idx").on(
|
||||
table.workspaceId,
|
||||
),
|
||||
userIdx: index("chat_channel_members_workspace_id_user_id_idx").on(
|
||||
table.workspaceId,
|
||||
table.userId,
|
||||
),
|
||||
channelFk: foreignKey({
|
||||
columns: [table.workspaceId, table.channelId],
|
||||
foreignColumns: [chatChannels.workspaceId, chatChannels.id],
|
||||
name: "chat_channel_members_channel_fk",
|
||||
}).onDelete("cascade"),
|
||||
memberFk: foreignKey({
|
||||
columns: [table.workspaceId, table.userId],
|
||||
foreignColumns: [workspaceMembers.workspaceId, workspaceMembers.userId],
|
||||
name: "chat_channel_members_workspace_member_fk",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const chatMessages = pgTable(
|
||||
"chat_messages",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
workspaceId: uuid("workspace_id")
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
channelId: uuid("channel_id").notNull(),
|
||||
sequence: bigint("sequence", { mode: "bigint" }).notNull(),
|
||||
parentMessageId: uuid("parent_message_id"),
|
||||
authorType: varchar("author_type", { length: 16 })
|
||||
.notNull()
|
||||
.$type<"human" | "agent" | "system">(),
|
||||
authorUserId: uuid("author_user_id"),
|
||||
/** Opaque, pinned agent identity until the agent registry schema exists. */
|
||||
authorAgentId: uuid("author_agent_id"),
|
||||
body: text("body").notNull(),
|
||||
/** Pinned send intent survives edits and tombstones. */
|
||||
sendIntentFingerprint: text("send_intent_fingerprint"),
|
||||
revision: integer("revision").notNull().default(1),
|
||||
status: varchar("status", { length: 16 })
|
||||
.notNull()
|
||||
.default("sent")
|
||||
.$type<"sent" | "edited" | "deleted">(),
|
||||
clientIdempotencyKey: varchar("client_idempotency_key", {
|
||||
length: 200,
|
||||
}).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
editedAt: timestamp("edited_at", { withTimezone: true }),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
workspaceIdx: index("chat_messages_workspace_id_idx").on(table.workspaceId),
|
||||
revisionCheck: check(
|
||||
"chat_messages_revision_check",
|
||||
sql`${table.revision} > 0`,
|
||||
),
|
||||
workspaceIdUnique: unique("chat_messages_workspace_id_id_unique").on(
|
||||
table.workspaceId,
|
||||
table.id,
|
||||
),
|
||||
workspaceChannelIdUnique: unique(
|
||||
"chat_messages_workspace_channel_id_unique",
|
||||
).on(table.workspaceId, table.channelId, table.id),
|
||||
channelSequenceUnique: unique(
|
||||
"chat_messages_workspace_channel_sequence_unique",
|
||||
).on(table.workspaceId, table.channelId, table.sequence),
|
||||
clientKeyUnique: unique(
|
||||
"chat_messages_workspace_channel_client_key_unique",
|
||||
).on(table.workspaceId, table.channelId, table.clientIdempotencyKey),
|
||||
channelFk: foreignKey({
|
||||
columns: [table.workspaceId, table.channelId],
|
||||
foreignColumns: [chatChannels.workspaceId, chatChannels.id],
|
||||
name: "chat_messages_channel_fk",
|
||||
}).onDelete("cascade"),
|
||||
parentFk: foreignKey({
|
||||
columns: [table.workspaceId, table.channelId, table.parentMessageId],
|
||||
foreignColumns: [table.workspaceId, table.channelId, table.id],
|
||||
name: "chat_messages_parent_fk",
|
||||
}),
|
||||
humanAuthorFk: foreignKey({
|
||||
columns: [table.workspaceId, table.authorUserId],
|
||||
foreignColumns: [workspaceMembers.workspaceId, workspaceMembers.userId],
|
||||
name: "chat_messages_human_author_member_fk",
|
||||
}),
|
||||
authorCheck: check(
|
||||
"chat_messages_author_check",
|
||||
sql`(${table.authorType} = 'human' and ${table.authorUserId} is not null and ${table.authorAgentId} is null) or (${table.authorType} = 'agent' and ${table.authorUserId} is null and ${table.authorAgentId} is not null) or (${table.authorType} = 'system' and ${table.authorUserId} is null and ${table.authorAgentId} is null)`,
|
||||
),
|
||||
statusCheck: check(
|
||||
"chat_messages_status_check",
|
||||
sql`${table.status} in ('sent', 'edited', 'deleted')`,
|
||||
),
|
||||
sequenceCheck: check(
|
||||
"chat_messages_sequence_check",
|
||||
sql`${table.sequence} >= 0`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const chatMessageMentions = pgTable(
|
||||
"chat_message_mentions",
|
||||
{
|
||||
workspaceId: uuid("workspace_id")
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
messageId: uuid("message_id").notNull(),
|
||||
userId: uuid("user_id").notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({
|
||||
columns: [table.workspaceId, table.messageId, table.userId],
|
||||
}),
|
||||
workspaceIdx: index("chat_message_mentions_workspace_id_idx").on(
|
||||
table.workspaceId,
|
||||
),
|
||||
messageFk: foreignKey({
|
||||
columns: [table.workspaceId, table.messageId],
|
||||
foreignColumns: [chatMessages.workspaceId, chatMessages.id],
|
||||
name: "chat_message_mentions_message_fk",
|
||||
}).onDelete("cascade"),
|
||||
mentionedMemberFk: foreignKey({
|
||||
columns: [table.workspaceId, table.userId],
|
||||
foreignColumns: [workspaceMembers.workspaceId, workspaceMembers.userId],
|
||||
name: "chat_message_mentions_workspace_member_fk",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const chatMessageMutations = pgTable(
|
||||
"chat_message_mutations",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
workspaceId: uuid("workspace_id")
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
channelId: uuid("channel_id").notNull(),
|
||||
messageId: uuid("message_id").notNull(),
|
||||
mutationSequence: bigint("mutation_sequence", { mode: "bigint" }).notNull(),
|
||||
revision: integer("revision").notNull(),
|
||||
kind: varchar("kind", { length: 16 })
|
||||
.notNull()
|
||||
.$type<"edited" | "deleted">(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
workspaceIdx: index("chat_message_mutations_workspace_id_idx").on(
|
||||
table.workspaceId,
|
||||
),
|
||||
sequenceUnique: unique("chat_message_mutations_channel_sequence_unique").on(
|
||||
table.workspaceId,
|
||||
table.channelId,
|
||||
table.mutationSequence,
|
||||
),
|
||||
channelFk: foreignKey({
|
||||
columns: [table.workspaceId, table.channelId],
|
||||
foreignColumns: [chatChannels.workspaceId, chatChannels.id],
|
||||
name: "chat_message_mutations_channel_fk",
|
||||
}).onDelete("cascade"),
|
||||
messageFk: foreignKey({
|
||||
columns: [table.workspaceId, table.messageId],
|
||||
foreignColumns: [chatMessages.workspaceId, chatMessages.id],
|
||||
name: "chat_message_mutations_message_fk",
|
||||
}).onDelete("cascade"),
|
||||
revisionCheck: check(
|
||||
"chat_message_mutations_revision_check",
|
||||
sql`${table.revision} > 1`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const chatReadCursors = pgTable(
|
||||
"chat_read_cursors",
|
||||
{
|
||||
workspaceId: uuid("workspace_id")
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
channelId: uuid("channel_id").notNull(),
|
||||
userId: uuid("user_id").notNull(),
|
||||
sequence: bigint("sequence", { mode: "bigint" })
|
||||
.notNull()
|
||||
.default(sql`0`),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({
|
||||
columns: [table.workspaceId, table.channelId, table.userId],
|
||||
}),
|
||||
workspaceIdx: index("chat_read_cursors_workspace_id_idx").on(
|
||||
table.workspaceId,
|
||||
),
|
||||
channelFk: foreignKey({
|
||||
columns: [table.workspaceId, table.channelId],
|
||||
foreignColumns: [chatChannels.workspaceId, chatChannels.id],
|
||||
name: "chat_read_cursors_channel_fk",
|
||||
}).onDelete("cascade"),
|
||||
readerMemberFk: foreignKey({
|
||||
columns: [table.workspaceId, table.userId],
|
||||
foreignColumns: [workspaceMembers.workspaceId, workspaceMembers.userId],
|
||||
name: "chat_read_cursors_workspace_member_fk",
|
||||
}),
|
||||
sequenceCheck: check(
|
||||
"chat_read_cursors_sequence_check",
|
||||
sql`${table.sequence} >= 0`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const chatMutationReceipts = pgTable(
|
||||
"chat_mutation_receipts",
|
||||
{
|
||||
workspaceId: uuid("workspace_id")
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
actorUserId: uuid("actor_user_id").notNull(),
|
||||
clientMutationId: uuid("client_mutation_id").notNull(),
|
||||
operation: varchar("operation", { length: 50 }).notNull(),
|
||||
fingerprint: text("fingerprint").notNull(),
|
||||
result: jsonb("result").$type<Record<string, unknown>>().notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({
|
||||
columns: [table.workspaceId, table.actorUserId, table.clientMutationId],
|
||||
}),
|
||||
workspaceIdx: index("chat_mutation_receipts_workspace_id_idx").on(
|
||||
table.workspaceId,
|
||||
),
|
||||
actorFk: foreignKey({
|
||||
columns: [table.workspaceId, table.actorUserId],
|
||||
foreignColumns: [workspaceMembers.workspaceId, workspaceMembers.userId],
|
||||
name: "chat_mutation_receipts_workspace_member_fk",
|
||||
}).onDelete("cascade"),
|
||||
}),
|
||||
);
|
||||
|
||||
export const chatOutbox = pgTable(
|
||||
"chat_outbox",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
workspaceId: uuid("workspace_id")
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
channelId: uuid("channel_id").notNull(),
|
||||
messageId: uuid("message_id"),
|
||||
eventVersion: integer("event_version").notNull().default(1),
|
||||
eventType: varchar("event_type", { length: 100 }).notNull(),
|
||||
dedupeKey: varchar("dedupe_key", { length: 250 }).notNull(),
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
publishedAt: timestamp("published_at", { withTimezone: true }),
|
||||
attempts: integer("attempts").notNull().default(0),
|
||||
lastError: text("last_error"),
|
||||
},
|
||||
(table) => ({
|
||||
workspaceIdx: index("chat_outbox_workspace_id_idx").on(table.workspaceId),
|
||||
unpublishedIdx: index("chat_outbox_unpublished_idx")
|
||||
.on(table.createdAt)
|
||||
.where(sql`${table.publishedAt} is null`),
|
||||
dedupeUnique: unique("chat_outbox_workspace_id_dedupe_key_unique").on(
|
||||
table.workspaceId,
|
||||
table.dedupeKey,
|
||||
),
|
||||
channelFk: foreignKey({
|
||||
columns: [table.workspaceId, table.channelId],
|
||||
foreignColumns: [chatChannels.workspaceId, chatChannels.id],
|
||||
name: "chat_outbox_channel_fk",
|
||||
}).onDelete("cascade"),
|
||||
messageFk: foreignKey({
|
||||
columns: [table.workspaceId, table.messageId],
|
||||
foreignColumns: [chatMessages.workspaceId, chatMessages.id],
|
||||
name: "chat_outbox_message_fk",
|
||||
}).onDelete("cascade"),
|
||||
versionCheck: check(
|
||||
"chat_outbox_event_version_check",
|
||||
sql`${table.eventVersion} > 0`,
|
||||
),
|
||||
attemptsCheck: check(
|
||||
"chat_outbox_attempts_check",
|
||||
sql`${table.attempts} >= 0`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -22,3 +22,4 @@ export * from "./harness_providers";
|
|||
export * from "./convoy_operator_messages";
|
||||
export * from "./completion_artifacts";
|
||||
export * from "./budget_caps";
|
||||
export * from "./chat";
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./chat": "./src/chat/index.ts",
|
||||
"./connected-repos": "./src/connected-repos/index.ts",
|
||||
"./convoy-lifecycle": "./src/convoy-lifecycle/index.ts",
|
||||
"./orchestrator": "./src/orchestrator/index.ts",
|
||||
|
|
|
|||
540
packages/shared/src/chat/contracts.test.ts
Normal file
540
packages/shared/src/chat/contracts.test.ts
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CHAT_IDEMPOTENCY_KEY_MAX_LENGTH,
|
||||
CHAT_MESSAGE_MAX_BODY_LENGTH,
|
||||
chatCommandSchema,
|
||||
chatCursorSchema,
|
||||
chatSequenceSchema,
|
||||
chatContextSchema,
|
||||
chatMessageCreatedV1Schema,
|
||||
chatPolicyInputSchema,
|
||||
canPerformChatAction,
|
||||
getChatCommandAuthorizationAction,
|
||||
} from "./index";
|
||||
|
||||
describe("chat decimal cursors", () => {
|
||||
it("accepts the full signed PostgreSQL bigint range without JS number loss", () => {
|
||||
expect(chatSequenceSchema.parse("9223372036854775807")).toBe(
|
||||
"9223372036854775807",
|
||||
);
|
||||
expect(chatCursorSchema.parse("0")).toBe("0");
|
||||
expect(
|
||||
chatCommandSchema.parse({
|
||||
type: "mark_read",
|
||||
workspaceId: "11111111-1111-4111-8111-111111111111",
|
||||
channelId: "33333333-3333-4333-8333-333333333333",
|
||||
context: {
|
||||
type: "workspace",
|
||||
workspaceId: "11111111-1111-4111-8111-111111111111",
|
||||
},
|
||||
sequence: "9007199254740993",
|
||||
}),
|
||||
).toMatchObject({ sequence: "9007199254740993" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"00",
|
||||
"01",
|
||||
"-1",
|
||||
"+1",
|
||||
"1.0",
|
||||
" 1",
|
||||
"1e3",
|
||||
"9223372036854775808",
|
||||
1,
|
||||
])("rejects malformed cursor %s", (value) => {
|
||||
expect(chatCursorSchema.safeParse(value).success).toBe(false);
|
||||
});
|
||||
it("requires a positive message sequence and rejects numeric mark-read", () => {
|
||||
expect(chatSequenceSchema.safeParse("0").success).toBe(false);
|
||||
expect(
|
||||
chatMessageCreatedV1Schema.safeParse({
|
||||
eventType: "chat.message.created.v1",
|
||||
eventVersion: 1,
|
||||
workspaceId: "11111111-1111-4111-8111-111111111111",
|
||||
channelId: "33333333-3333-4333-8333-333333333333",
|
||||
messageId: "44444444-4444-4444-8444-444444444444",
|
||||
sequence: "9223372036854775808",
|
||||
author: {
|
||||
type: "human",
|
||||
userId: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
body: "hello",
|
||||
parentMessageId: null,
|
||||
mentionedUserIds: [],
|
||||
clientIdempotencyKey: "key",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const workspaceId = "11111111-1111-4111-8111-111111111111";
|
||||
const userId = "22222222-2222-4222-8222-222222222222";
|
||||
const channelId = "33333333-3333-4333-8333-333333333333";
|
||||
const messageId = "44444444-4444-4444-8444-444444444444";
|
||||
const otherId = "55555555-5555-4555-8555-555555555555";
|
||||
const targetUserId = "66666666-6666-4666-8666-666666666666";
|
||||
const context = { type: "workspace" as const, workspaceId };
|
||||
|
||||
const actor = {
|
||||
userId,
|
||||
workspaceId,
|
||||
activeWorkspaceMember: true,
|
||||
role: "member" as const,
|
||||
};
|
||||
const channel = {
|
||||
id: channelId,
|
||||
workspaceId,
|
||||
kind: "public" as const,
|
||||
archived: false,
|
||||
activeMember: false,
|
||||
ownerUserId: otherId,
|
||||
};
|
||||
const base = { actor, context, channel };
|
||||
describe("chat.message.created.v1 contract", () => {
|
||||
const event = {
|
||||
eventType: "chat.message.created.v1",
|
||||
eventVersion: 1,
|
||||
workspaceId,
|
||||
channelId,
|
||||
messageId,
|
||||
sequence: "42",
|
||||
author: { type: "human", userId },
|
||||
body: "hello",
|
||||
parentMessageId: null,
|
||||
mentionedUserIds: [targetUserId],
|
||||
clientIdempotencyKey: "local-1",
|
||||
} as const;
|
||||
|
||||
it("accepts the strict versioned payload with a serialized bigint", () => {
|
||||
expect(chatMessageCreatedV1Schema.parse(event)).toEqual(event);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ sequence: "0" },
|
||||
{ sequence: "1.5" },
|
||||
{ eventVersion: 2 },
|
||||
{ unexpected: true },
|
||||
])("rejects invalid payload fields %#", (override) => {
|
||||
expect(
|
||||
chatMessageCreatedV1Schema.safeParse({ ...event, ...override }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const message = {
|
||||
id: messageId,
|
||||
workspaceId,
|
||||
channelId,
|
||||
author: { type: "human" as const, userId },
|
||||
};
|
||||
|
||||
function validCommands() {
|
||||
const scope = { workspaceId, channelId, context };
|
||||
return [
|
||||
{
|
||||
...scope,
|
||||
type: "send_message" as const,
|
||||
body: "hello",
|
||||
clientIdempotencyKey: "local-1",
|
||||
},
|
||||
{
|
||||
...scope,
|
||||
type: "edit_message" as const,
|
||||
messageId,
|
||||
body: "updated",
|
||||
expectedRevision: 1,
|
||||
clientMutationId: messageId,
|
||||
},
|
||||
{
|
||||
...scope,
|
||||
type: "delete_message" as const,
|
||||
messageId,
|
||||
expectedRevision: 1,
|
||||
clientMutationId: messageId,
|
||||
},
|
||||
{ ...scope, type: "mark_read" as const, sequence: "1" },
|
||||
{ ...scope, type: "archive_channel" as const },
|
||||
{
|
||||
...scope,
|
||||
type: "change_membership" as const,
|
||||
userId,
|
||||
operation: "add" as const,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe("chat command contracts", () => {
|
||||
it("accepts every command variant", () => {
|
||||
for (const command of validCommands())
|
||||
expect(chatCommandSchema.safeParse(command).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ type: "object", workspaceId, objectId: messageId },
|
||||
{ type: "future", workspaceId },
|
||||
])("rejects unsupported context $type", (value) =>
|
||||
expect(chatContextSchema.safeParse(value).success).toBe(false),
|
||||
);
|
||||
|
||||
it("requires matching workspace context on every variant", () => {
|
||||
for (const command of validCommands()) {
|
||||
expect(
|
||||
chatCommandSchema.safeParse({
|
||||
...command,
|
||||
context: { ...context, workspaceId: otherId },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
chatCommandSchema.safeParse({
|
||||
...command,
|
||||
context: { type: "object", workspaceId },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown keys on every variant", () => {
|
||||
for (const command of validCommands()) {
|
||||
expect(
|
||||
chatCommandSchema.safeParse({ ...command, unexpected: true }).success,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces numeric, body, and idempotency bounds", () => {
|
||||
expect(
|
||||
chatCommandSchema.safeParse({ ...validCommands()[3], sequence: -1 })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
chatCommandSchema.safeParse({
|
||||
...validCommands()[0],
|
||||
body: "x".repeat(CHAT_MESSAGE_MAX_BODY_LENGTH + 1),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
chatCommandSchema.safeParse({
|
||||
...validCommands()[0],
|
||||
clientIdempotencyKey: "x".repeat(CHAT_IDEMPOTENCY_KEY_MAX_LENGTH + 1),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("maps every command to a non-weaker authorization action", () => {
|
||||
expect(validCommands().map(getChatCommandAuthorizationAction)).toEqual([
|
||||
"send",
|
||||
"edit_own",
|
||||
"delete_message",
|
||||
"read",
|
||||
"manage_channel",
|
||||
"manage_members",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat authorization policy", () => {
|
||||
it("explicitly permits public reads and sends but fails closed for unknown actions", () => {
|
||||
expect(canPerformChatAction({ ...base, action: "read" })).toBe(true);
|
||||
expect(canPerformChatAction({ ...base, action: "send" })).toBe(true);
|
||||
expect(canPerformChatAction({ ...base, action: "future_action" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("denies inactive and cross-workspace access", () => {
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "read",
|
||||
actor: { ...actor, activeWorkspaceMember: false },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "read",
|
||||
actor: { ...actor, workspaceId: otherId },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["private", "direct"] as const)(
|
||||
"requires active membership for %s",
|
||||
(kind) => {
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "read",
|
||||
channel: { ...channel, kind },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "read",
|
||||
channel: { ...channel, kind, activeMember: true },
|
||||
}),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("denies every archived mutation but permits authorized read", () => {
|
||||
const archived = { ...channel, archived: true, activeMember: true };
|
||||
expect(
|
||||
canPerformChatAction({ ...base, action: "read", channel: archived }),
|
||||
).toBe(true);
|
||||
const inputs = [
|
||||
{ ...base, action: "send", channel: archived },
|
||||
{ ...base, action: "edit_own", channel: archived, message },
|
||||
{
|
||||
...base,
|
||||
action: "moderate",
|
||||
channel: archived,
|
||||
message,
|
||||
actor: { ...actor, role: "admin" as const },
|
||||
},
|
||||
{
|
||||
...base,
|
||||
action: "manage_channel",
|
||||
channel: archived,
|
||||
actor: { ...actor, role: "admin" as const },
|
||||
},
|
||||
{
|
||||
...base,
|
||||
action: "manage_members",
|
||||
channel: archived,
|
||||
operation: "add",
|
||||
target: {
|
||||
userId: otherId,
|
||||
workspaceId,
|
||||
activeWorkspaceMember: true,
|
||||
activeChannelMember: false,
|
||||
},
|
||||
actor: { ...actor, role: "admin" as const },
|
||||
},
|
||||
{ ...base, action: "join", channel: archived },
|
||||
];
|
||||
for (const input of inputs) expect(canPerformChatAction(input)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires matching message scope and ownership for edit", () => {
|
||||
expect(canPerformChatAction({ ...base, action: "edit_own", message })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(canPerformChatAction({ ...base, action: "edit_own" })).toBe(false);
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "edit_own",
|
||||
message: { ...message, channelId: otherId },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "edit_own",
|
||||
message: { ...message, workspaceId: otherId },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "edit_own",
|
||||
message: { ...message, author: { type: "human", userId: otherId } },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "edit_own",
|
||||
message: { ...message, author: { type: "agent", agentId: otherId } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows authors to delete their own message and moderators to delete others", () => {
|
||||
expect(
|
||||
canPerformChatAction({ ...base, action: "delete_message", message }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "delete_message",
|
||||
message: { ...message, author: { type: "human", userId: otherId } },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "delete_message",
|
||||
actor: { ...actor, role: "admin" },
|
||||
message: { ...message, author: { type: "human", userId: otherId } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires message scope, admin role, and access for moderation", () => {
|
||||
const admin = { ...actor, role: "admin" as const };
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "moderate",
|
||||
actor: admin,
|
||||
message,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canPerformChatAction({ ...base, action: "moderate", actor: admin }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "moderate",
|
||||
actor: admin,
|
||||
message: { ...message, workspaceId: otherId },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "moderate",
|
||||
actor: admin,
|
||||
message,
|
||||
channel: { ...channel, kind: "private" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows channel owner or workspace admin to manage a channel, subject to access", () => {
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "manage_channel",
|
||||
channel: { ...channel, ownerUserId: userId },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "manage_channel",
|
||||
actor: { ...actor, role: "admin" },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(canPerformChatAction({ ...base, action: "manage_channel" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "manage_channel",
|
||||
actor: { ...actor, role: "admin" },
|
||||
channel: { ...channel, kind: "private" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("applies operation-specific membership target rules", () => {
|
||||
const admin = { ...actor, role: "admin" as const };
|
||||
const target = {
|
||||
userId: targetUserId,
|
||||
workspaceId,
|
||||
activeWorkspaceMember: true,
|
||||
activeChannelMember: false,
|
||||
};
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "manage_members",
|
||||
operation: "add",
|
||||
actor: admin,
|
||||
target,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
const manage = (operation: "add" | "remove", overrides = {}) =>
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "manage_members",
|
||||
operation,
|
||||
actor: admin,
|
||||
channel: { ...channel, kind: "private", activeMember: true },
|
||||
target: { ...target, ...overrides },
|
||||
});
|
||||
|
||||
expect(manage("add")).toBe(true);
|
||||
expect(manage("add", { activeWorkspaceMember: false })).toBe(false);
|
||||
expect(manage("add", { activeChannelMember: true })).toBe(false);
|
||||
expect(manage("remove", { activeChannelMember: false })).toBe(false);
|
||||
expect(
|
||||
manage("remove", {
|
||||
activeWorkspaceMember: false,
|
||||
activeChannelMember: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
manage("remove", { workspaceId: otherId, activeChannelMember: true }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("never permits removing the channel owner", () => {
|
||||
const ownerTarget = {
|
||||
userId: channel.ownerUserId,
|
||||
workspaceId,
|
||||
activeWorkspaceMember: true,
|
||||
activeChannelMember: true,
|
||||
};
|
||||
for (const role of ["owner", "admin"] as const) {
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "manage_members",
|
||||
operation: "remove",
|
||||
actor: { ...actor, role },
|
||||
channel: { ...channel, kind: "private", activeMember: true },
|
||||
target: ownerTarget,
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps direct membership immutable and denies admin self-join", () => {
|
||||
const admin = { ...actor, role: "admin" as const };
|
||||
const target = {
|
||||
userId: otherId,
|
||||
workspaceId,
|
||||
activeWorkspaceMember: true,
|
||||
activeChannelMember: false,
|
||||
};
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "manage_members",
|
||||
operation: "add",
|
||||
actor: admin,
|
||||
target,
|
||||
channel: { ...channel, kind: "direct", activeMember: true },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canPerformChatAction({
|
||||
...base,
|
||||
action: "join",
|
||||
actor: admin,
|
||||
channel: { ...channel, kind: "private" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fails schema validation for unsupported contexts", () => {
|
||||
expect(
|
||||
chatPolicyInputSchema.safeParse({
|
||||
...base,
|
||||
action: "read",
|
||||
context: { type: "object", workspaceId },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue