Skip to content

feat: PR 7 — drop badge_counts table; per-user DO storage; JWT-authed badge endpoints#2961

Merged
iscekic merged 101 commits intofeat/kilo-chat-migration-pr1from
feat/kilo-chat-migration-pr7
Apr 30, 2026
Merged

feat: PR 7 — drop badge_counts table; per-user DO storage; JWT-authed badge endpoints#2961
iscekic merged 101 commits intofeat/kilo-chat-migration-pr1from
feat/kilo-chat-migration-pr7

Conversation

@iscekic
Copy link
Copy Markdown
Contributor

@iscekic iscekic commented Apr 30, 2026

Summary

Final PR in the kilo-chat migration stack (PR 7 of 7). Moves per-user badge state out of Postgres into per-user NotificationChannelDO storage, re-keys the DO routing to idFromName(userId), and serves badge reads/writes directly from the notifications worker over JWT-authed HTTP. Drops the now-unused badge_counts table.

Stacked on feat/kilo-chat-migration-pr6 (#2958). Merge that first.

Why

PR 3 wired badge state through Postgres badge_counts because the schema was already in place from the Stream era. With the cutover complete and NotificationChannelDO already serializing per-user dispatch, the table adds nothing the DO storage can't do — and routing the DO by conversationId while every internal operation is per-user was a latent design mismatch worth fixing in the same change.

Scope (Phase 18, Tasks 63–67)

  • Task 63 — Re-key NotificationChannelDO to idFromName(userId); replace Drizzle badge_counts math with per-user DO storage (bucket:${badgeBucket} → number). New private helpers incrementBucket/getTotal; new public RPCs markBucketRead/listNonZeroBuckets. Tests updated; new badge-storage.test.ts.
  • Task 64 — JWT auth middleware on notifications (mirrors services/kilo-chat/src/auth.ts, uses NEXTAUTH_SECRET). New GET /v1/badges and POST /v1/badges/mark-read HTTP routes mounted under /v1/* with CORS + auth. useWorkersLogger mounted so setTags actually establishes the ALS frame. New auth.test.ts and routes-badges.test.ts (5 + 6 cases). Wrangler binds NEXTAUTH_SECRET from the secrets store.
  • Task 65 — Removed markChatRead and getUnreadCounts tRPC procedures from apps/web/src/routers/user-router.ts plus matching test cases.
  • Task 66 — Mobile calls ${EXPO_PUBLIC_NOTIFICATIONS_URL}/v1/badges and /v1/badges/mark-read directly using the existing Kilo JWT (token via useKiloChatTokenGetter). Updated use-unread-counts, use-unread-counts-invalidation, and use-mark-read. Query key migrated to ['badges', userId].
  • Task 67 — Dropped the badge_counts table + BadgeCount type; generated migration 0108_drop_badge_counts.sql (DROP TABLE "badge_counts" CASCADE;).

Hard cutover

Existing badge_counts rows are discarded. Counts reset to 0 and re-populate on the next push. Acceptable because the dataset is transient.

The DO routing flip means in-flight idempotency keys re-shard during deploy; the 1h TTL means worst case is duplicate suppression for a few minutes during the deploy window. Deploy during low traffic.

Deploy ordering (Task 67b)

  1. Provision the notifications.kiloapps.io custom domain ahead of merge.
  2. Deploy the worker (Tasks 63–66) to Cloudflare before applying migration 0108. With badge_counts no longer referenced in any worker, the DROP TABLE is safe.
  3. Apply migration 0108 on production Postgres.
  4. Add EXPO_PUBLIC_NOTIFICATIONS_URL=https://notifications.kiloapps.io to the EAS env so the next mobile build picks it up.

Test plan

  • CI green (typecheck + tests on services/notifications, apps/web, apps/mobile, packages/db).
  • Manual QA on prod after deploy: open a chat → push fires → bucket appears in GET /v1/badges → opening the chat clears it via POST /v1/badges/mark-read and decrements the OS badge.
  • Verify cross-user isolation (covered by routes-badges.test.ts "isolates buckets per caller", but spot-check live).
  • Verify CORS preflight from web app origin to notifications.kiloapps.io.

Notes

  • pnpm run validate is red on 2 pre-existing oxlint errors in packages/kilo-chat/src/utils.ts (no-non-null-assertion/no-unnecessary-type-assertion on bytes[i]!); not introduced here.
  • Stale doc comment in packages/notifications/src/badge-buckets.ts updated to describe the DO-storage layout instead of the dropped table.

iscekic added 30 commits April 29, 2026 17:28
The badge_counts.badge_bucket column is a free-form string. To prevent
namespace collisions as more surfaces start emitting badge updates
(per-instance today, per-conversation later), centralize bucket-key
derivation in @kilocode/notifications and route NotificationChannelDO
through it. Mirrors the presence-context builders in @kilocode/event-service.

Safe to introduce now without a data migration because PR 2's migration
already wipes badge_counts.
…-chat producer

Adds kiloclawInstanceContext and kiloclawConversationContext path
builders to @kilocode/event-service, replacing hardcoded template
literals in kilo-chat's event-push.ts and its test so all callers
share a single source of truth.
When a chat message is persisted, fire-and-forget a call to
NOTIFICATIONS.sendPushForConversation so non-sender human members of the
conversation receive a push. Runs after realtime/event-service delivery
inside postCommitFanOut, with errors swallowed so push failures cannot
fail the send.

- Skip when there are no other human recipients or no sandboxId.
- senderUserId = callerId for human senders, null for bot senders.
- title is "<sandboxLabel> · <conversationTitle>"; bodyPreview is the
  first 200 chars of the concatenated text blocks.
- Add @kilocode/notifications workspace dep and layer the RPC method
  shape into Env via bindings.d.ts.
- Add a notifications-stub worker to the vitest config so tests can
  spy on env.NOTIFICATIONS.sendPushForConversation, and globally mock
  sandbox-lookup in setup.ts (it imports pg via @kilocode/db).
…es, fix test mock

- Remove `stream-chat` from `services/notifications/package.json`; the Stream
  webhook (its only consumer) was deleted earlier in the stack.
- Regenerate `worker-configuration.d.ts` so the workerd runtime types match the
  current toolchain (sibling services were on `1.20260312.1`; this one had
  drifted to `1.20251217.0` from a stale local cache).
- Fix the global test mock to reference the renamed `badge_counts` table; the
  setup file was authored against the pre-rename name and never matched.
- Tidy two pre-existing lint nits in the new test files (`import type` for
  type-only import, drop unused `cols` parameter).
…leak

- Switch `NotificationsService` from default-only to a named class export
  with a separate default. `services/kilo-chat/wrangler.jsonc` binds via
  `entrypoint: "NotificationsService"`, which resolves named module
  exports. The default-only form (`export default class NotificationsService`)
  exports under the `default` key — kilo-chat's RPC binding would not have
  resolved at deploy. Mirrors the existing pattern in
  `services/kilo-chat/src/index.ts` (`KiloChatService`).

- `dispatchPush` now uses a two-stage idempotency record (`pending` →
  `delivered`). The badge increment was previously non-idempotent: an
  Expo failure returned `failed` without writing the idempotency key, so
  upstream retries (which the design explicitly invites) re-ran the
  increment before the next send and inflated the badge by one per
  retry. The `pending` marker is written before the increment and
  short-circuits the increment on retry; the `delivered` marker is only
  written on success.

- `setAlarm` is now gated on `getAlarm() === null`. Calling `setAlarm`
  unconditionally on each successful push — as the previous code did —
  replaces the pending alarm and pushes the cleanup forward indefinitely
  on a conversation receiving more than one push per `IDEM_TTL_MS`,
  leaking expired idempotency entries.

Adds two test cases covering the badge-retry and alarm-reset paths.
- Schedule the cleanup alarm when writing the `pending` marker, not only
  on `delivered`. Without this, an Expo failure followed by no further
  push activity for the conversation leaves the `pending` record in DO
  storage forever (no alarm was ever set to prune it).

- After the alarm fires, reschedule for the earliest remaining record's
  expiry instead of leaving the alarm slot empty. Otherwise a quiet
  conversation strands its younger entries until some unrelated future
  dispatch wakes the DO up.

Both paths go through a small `ensureCleanupAlarm` helper that gates on
`getAlarm() === null` so a busy conversation still doesn't push the
alarm forward on every call.
The kiloclaw-scoped presence paths are literally `/presence` prefixed
onto the kiloclaw event-context paths. Build them by composition so the
`/kiloclaw/{sandboxId}[/{conversationId}]` segment shape is defined in
exactly one place — `kiloclaw-contexts.ts`.

Pure refactor; same string output, template-literal types still narrow
to the same shape.
Introduces a single app-shell EventServiceProvider that owns the
EventServiceClient and KiloChatClient for all authenticated routes.
Mounted in (app)/layout.tsx so platform/instance/conversation presence
subscriptions and the kilo-chat UI share one WebSocket.

KiloChatLayout now consumes the global clients via useEventServiceClient()
instead of spinning up its own pair, and the getToken prop is removed from
KiloChatLayoutProps (along with both call sites). The local
useEventService(getToken) factory is dead code and has been deleted;
useInstanceContext / useConversationContext stay since they take
EventServiceClient as a parameter.
Thin hook that subscribes the global EventServiceClient to a single
context for the lifetime of the calling component, gated by an `active`
flag. Will back upcoming platform- and instance-level presence
indicators.
…eSubscription

- Drop dead getToken field from KiloChatContextValue (no consumers).
- Remove useInstanceContext / useConversationContext hooks; both call
  sites now use the shared usePresenceSubscription primitive directly.
- Harden usePresenceSubscription against empty-string contexts.
- usePresenceSubscription: accept 'string | null' instead of empty-string
  sentinel; update call sites (KiloChatLayout, MessageArea, useInstancePresence)
- kilo-chat router: validate expiresAt with z.iso.datetime()
- kilo-chat-router test: verify the JWT payload (kiloUserId, tokenSource,
  version) and that expiresAt lands in the expected ~1h window
- MessageArea: comment distinguishing the always-on chat-event subscription
  from the visibility-gated presence subscription
Multiple consumers can now independently hold the same context without
trampling each other. The wire context.subscribe/context.unsubscribe
messages are only sent on the 0->1 and 1->0 refcount transitions; the
intermediate churn stays client-side.

Resubscribe-on-reconnect dedupes by context key.

Tests cover: double-subscribe collapses to a single wire send, partial
unsubscribe keeps the context alive, last-consumer-out releases it,
mixed batches only send newly-active contexts, unknown-context
unsubscribes are no-ops, and reconnect resubscribes each context once.
iscekic added 19 commits April 30, 2026 13:52
…torage

Key NotificationChannelDO by recipient userId instead of conversationId,
and store per-bucket badge counts directly in DO storage under
`bucket:${badgeBucket}` keys. The Drizzle `badge_counts` insert/sum paths
are gone from the DO; sendPushForConversationCore now fans out to one DO
per recipient via idFromName(userId). Adds private incrementBucket /
getTotal helpers and public markBucketRead / listNonZeroBuckets RPC for
the upcoming HTTP routes.
Mirrors kilo-chat's auth middleware: bearer Kilo JWT verified against
NEXTAUTH_SECRET, callerId/callerKind set on context. Mounts CORS + auth
on /v1/* and adds GET /v1/badges + POST /v1/badges/mark-read backed by
NotificationChannelDO RPC methods.
Without the middleware, logger.setTags in authMiddleware writes to no
AsyncLocalStorage frame. Mirrors the kilo-chat setup. Also tightens the
mark-read missing-bucket test to lock the JSON error contract for mobile.
Remove markChatRead and getUnreadCounts from user router; mobile now
calls the notifications worker HTTP routes (GET /v1/badges,
POST /v1/badges/mark-read) added in tasks 63-64. The badge_counts
table itself is dropped in a follow-up.
Replace tRPC `user.getUnreadCounts` and `user.markChatRead` (deleted in
prior commit) with direct fetches to the notifications worker
(`GET /v1/badges`, `POST /v1/badges/mark-read`) authed with the existing
kilo-chat JWT. Adds `EXPO_PUBLIC_NOTIFICATIONS_URL` config and rekeys the
unread-counts query to `['badges', userId]`.
Badge state now lives in the notifications DO storage; the postgres
table is no longer read or written by any service.
The previous commit also moved NewSecurityAdvisorScan up next to
SecurityAdvisorScan as a cosmetic cleanup; that's out of scope for
the badge_counts removal. Restore the orphan to its original spot
at end-of-file so the badge_counts diff is minimal.
@iscekic iscekic self-assigned this Apr 30, 2026
Comment thread apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts
@kilo-code-bot
Copy link
Copy Markdown
Contributor

kilo-code-bot Bot commented Apr 30, 2026

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (1 file in incremental diff)
  • apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts

Reviewed by gpt-5.5-20260423 · 548,841 tokens

Base automatically changed from feat/kilo-chat-migration-pr6 to feat/kilo-chat-migration-pr1 April 30, 2026 13:50
…to feat/kilo-chat-migration-pr7

# Conflicts:
#	apps/mobile/.env
#	apps/mobile/src/app/(app)/_layout.tsx
#	apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts
#	apps/mobile/src/lib/config.ts
#	apps/mobile/src/lib/env-keys.js
#	apps/mobile/src/lib/hooks/use-unread-counts.ts
#	apps/web/src/routers/user-router.test.ts
#	apps/web/src/routers/user-router.ts
#	packages/db/src/migrations/meta/_journal.json
#	packages/db/src/schema.ts
#	packages/notifications/src/badge-buckets.ts
#	pnpm-lock.yaml
#	services/notifications/package.json
#	services/notifications/src/__tests__/dispatch-push.test.ts
#	services/notifications/src/__tests__/send-push-for-conversation.test.ts
#	services/notifications/src/__tests__/setup.ts
#	services/notifications/src/dos/NotificationChannelDO.ts
#	services/notifications/src/index.ts
#	services/notifications/worker-configuration.d.ts
@iscekic iscekic merged commit 99470bb into feat/kilo-chat-migration-pr1 Apr 30, 2026
1 check passed
@iscekic iscekic deleted the feat/kilo-chat-migration-pr7 branch April 30, 2026 13:55
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