Skip to content

feat(server): idempotent bulk message ingest + count for backfill parity#14

Open
andrei-hasna wants to merge 6 commits into
mainfrom
feat/bulk-message-ingest
Open

feat(server): idempotent bulk message ingest + count for backfill parity#14
andrei-hasna wants to merge 6 commits into
mainfrom
feat/bulk-message-ingest

Conversation

@andrei-hasna

Copy link
Copy Markdown
Contributor

What

Adds POST /v1/messages/bulk and GET /v1/messages?count=1 to the self_hosted conversations API so the authoritative local message store can be backfilled into the cloud Postgres to full parity.

Why

The fleet cutover exposed a split-brain: cloud had ~9 messages vs ~36k local. The only write path was POST /v1/messages, which can't set uuid/created_at (so it's neither idempotent nor history-preserving) and inserts one row per request. This adds a proper backfill path.

How

  • Bulk insert preserves each message's uuid + created_at (and all scalar fields).
  • Idempotent via ON CONFLICT (uuid) DO NOTHING — re-running a batch inserts 0.
  • Returns { requested, inserted, skipped, total }; total is the authoritative post-insert count.
  • conversations:write scope required; batches capped at 2000.
  • GET /v1/messages?count=1 reads the total without paging (list caps at 500).
  • pg-migrations: idempotent unique index on messages(uuid) to guarantee the conflict target.
  • OpenAPI + regenerated SDK (19 ops); tests cover idempotency, scope, validation, count.

Verification

  • bun run typecheck clean
  • bun test src/server/api.test.ts → 13 pass
  • bun test src/lib/pg-migrations.test.ts → 10 pass
  • Pre-existing failures (contract.test.ts, storage-sync.test.ts) confirmed present on base commit (environmental).

… parity

Adds POST /v1/messages/bulk so the authoritative local message store can be
backfilled into the cloud Postgres to full parity without duplicates:

- Preserves each message's uuid and created_at (and every scalar field) so the
  cloud copy mirrors local history rather than a batch of "now"-stamped rows.
- Idempotent via ON CONFLICT (uuid) DO NOTHING — re-running a batch inserts 0.
- Returns { requested, inserted, skipped, total } where total is the
  authoritative post-insert count, the API-visible parity signal.
- Guards with the conversations:write scope; caps batches at 2000.
- Adds GET /v1/messages?count=1 to read the total (filters honoured) without
  paging through every row (list is capped at 500).
- pg-migrations: idempotent CREATE UNIQUE INDEX on messages(uuid) to guarantee
  the ON CONFLICT target exists even on tables where uuid was added via ALTER.
- openapi + regenerated SDK (19 ops); api.test.ts covers idempotency, scope,
  validation, and count.
…ildable)

HEAD bumped @hasna/contracts to ^0.5.0 (for the client cloud-store path added
in #13) but never regenerated bun.lock, which still pins 0.4.1 — so
`bun install --frozen-lockfile` (the ECS Docker build) cannot resolve and the
image is unbuildable.

The serve process imports only @hasna/contracts/auth (present in 0.4.1) and the
vendored storage-kit (self-contained, KIT_VERSION 0.4.1); it never imports the
client-only cloud-store.ts. The deployed prod image already runs 0.4.1 auth, so
existing API keys stay valid. Pinning to ^0.4.1 restores a coherent, buildable
state for the server deploy.

The client cloud-store.ts (which needs contracts 0.5.x `client/storage`) is part
of the separate client-flip workstream: it will move to 0.5.x once contracts
0.5.x is published and the lockfile regenerated.
The CodeBuild fleet image builder pulls the base image anonymously from
docker.io, whose shared-IP rate limit (HTTP 429 Too Many Requests on
oven/bun:1) reliably fails the build. Switch the base to AWS's credential-free
ECR Public mirror of the Debian Official Image and install a pinned Bun via the
official installer. oven/bun:1 is itself Debian-based, so the runtime is
functionally equivalent while removing the docker.io dependency.
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

Shipped + backfill verified live (self_hosted, acct 789877399345, us-east-1)

Deployed: conversations-prod:3 on cluster oss-fleet-prod → image conversations@sha256:6e836103ae3922fa038fe5e4f7a35f230d40cf245d304a964da526448efba5cc (built by CodeBuild conversations-prod-image-builder). Rollout COMPLETED, 2/2 tasks, /version0.4.0.

Migration: one-shot migrate.ts task exited 0 — 19 tables present, uuid unique index ensured (ON CONFLICT target).

Auth safety: existing API key still returns HTTP 200 on /v1 after the deploy — no auth regression / no fleet lockout. Contracts pinned to 0.4.1 (same as the prior running image).

Backfill (local messages.db → cloud, idempotent):

  • Authoritative local snapshot: 35,998 messages
  • First run: inserted 35,996, skipped 2 (uuids already in cloud from prior split-brain writes)
  • Re-run: inserted 0, skipped 35,998 → idempotent confirmed
  • Cloud total: 36,006 (GET /v1/messages?count=1{"count":36006}), ≥ authoritative local ✅
  • created_at + uuid preserved (oldest evals msg 2026-03-27T14:55:35.183Z, uuid matches local id=1 — not a "now" stamp)

Build path self-healed: CodeBuild's anonymous docker.io pull of oven/bun:1 was failing with HTTP 429; base image moved to AWS ECR Public Debian mirror + pinned Bun.

Note: this backfill closes the message split-brain (cloud was ~9). The client read-path flip across machines remains a separate, atomically-gated step.

…rver read/receipt endpoints

Client (cloud-store): add cloud-routed readMessages, searchMessages, readDigest,
markRead/markReadByIds/markAllRead/markChannelRead/markSessionRead, markUnreadByIds,
listUnreadCounts, editMessage, pin/unpinMessage, getPinnedMessages,
recordReadReceiptsBatch, getReadReceipts. Each dispatches to
https://conversations.hasna.xyz/v1 with the bearer key when self_hosted, else
falls through to the local SQLite store (fully reversible). Coerce Postgres
bigint ids to numbers in parseMessage so cloud rows match the Message shape.

Wire the MCP tools (messaging, channels), the MCP notification poller, and the
CLI commands (read/channel/search/digest/mark/edit/pin/pinned/watch) to the
routed cloud-store facade so bin/mcp.js and bin/index.js actually hit the cloud
(previously 0 cloud refs in the MCP bundle).

Refactor readDigest into a storage-agnostic assembleDigest packer so the local
and cloud digest paths share one byte-budget algorithm.

Server (api.ts): extend GET /v1/messages with since, since_id, order, offset,
unread_only, q (ILIKE search), threads_only, mentions_only, include_reply_counts;
add POST /v1/messages/read + /unread, GET /v1/messages/unread-counts,
GET /v1/messages/pinned, GET/POST /v1/messages/:id/receipts,
POST /v1/messages/:id/pin|unpin, and PATCH /v1/messages/:id (edit). All additive
and backward-compatible.

Bump to 0.5.0.
…ilter

- MCP advanced.ts and CLI locks.ts lock-conflict auto-DMs now route through
  cloud-store sendMessage (self_hosted -> cloud API, else local), removing the
  last message-write split-brain when the fleet is flipped.
- server GET /v1/messages now honours ?uuid= for exact membership checks so
  backfill parity can be measured against the cloud uuid set (was ignored).
- read-only advanced tools (thread replies, mentions, message-read-status,
  getMessagesForAgent) still read local: documented residual, need new cloud
  endpoints; non-corrupting (read-only).
Report cloud-served message/unread counts and the API URL when
HASNA_CONVERSATIONS_* self_hosted routing is active, instead of the
stale local sqlite path. Falls back to the local db when not in cloud
mode. Adds cloudStatus()/cloudApiUrl() helpers to cloud-store.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant