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 Apr 30, 2026
Merged
Conversation
…rpose); update all consumers
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.
…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.
Contributor
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Files Reviewed (1 file in incremental diff)
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Final PR in the kilo-chat migration stack (PR 7 of 7). Moves per-user badge state out of Postgres into per-user
NotificationChannelDOstorage, re-keys the DO routing toidFromName(userId), and serves badge reads/writes directly from thenotificationsworker over JWT-authed HTTP. Drops the now-unusedbadge_countstable.Stacked on
feat/kilo-chat-migration-pr6(#2958). Merge that first.Why
PR 3 wired badge state through Postgres
badge_countsbecause the schema was already in place from the Stream era. With the cutover complete andNotificationChannelDOalready serializing per-user dispatch, the table adds nothing the DO storage can't do — and routing the DO byconversationIdwhile every internal operation is per-user was a latent design mismatch worth fixing in the same change.Scope (Phase 18, Tasks 63–67)
NotificationChannelDOtoidFromName(userId); replace Drizzlebadge_countsmath with per-user DO storage (bucket:${badgeBucket}→ number). New private helpersincrementBucket/getTotal; new public RPCsmarkBucketRead/listNonZeroBuckets. Tests updated; newbadge-storage.test.ts.notifications(mirrorsservices/kilo-chat/src/auth.ts, usesNEXTAUTH_SECRET). NewGET /v1/badgesandPOST /v1/badges/mark-readHTTP routes mounted under/v1/*with CORS + auth.useWorkersLoggermounted sosetTagsactually establishes the ALS frame. Newauth.test.tsandroutes-badges.test.ts(5 + 6 cases). Wrangler bindsNEXTAUTH_SECRETfrom the secrets store.markChatReadandgetUnreadCountstRPC procedures fromapps/web/src/routers/user-router.tsplus matching test cases.${EXPO_PUBLIC_NOTIFICATIONS_URL}/v1/badgesand/v1/badges/mark-readdirectly using the existing Kilo JWT (token viauseKiloChatTokenGetter). Updateduse-unread-counts,use-unread-counts-invalidation, anduse-mark-read. Query key migrated to['badges', userId].badge_countstable +BadgeCounttype; generated migration0108_drop_badge_counts.sql(DROP TABLE "badge_counts" CASCADE;).Hard cutover
Existing
badge_countsrows 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)
notifications.kiloapps.iocustom domain ahead of merge.badge_countsno longer referenced in any worker, theDROP TABLEis safe.EXPO_PUBLIC_NOTIFICATIONS_URL=https://notifications.kiloapps.ioto the EAS env so the next mobile build picks it up.Test plan
services/notifications,apps/web,apps/mobile,packages/db).GET /v1/badges→ opening the chat clears it viaPOST /v1/badges/mark-readand decrements the OS badge.routes-badges.test.ts"isolates buckets per caller", but spot-check live).notifications.kiloapps.io.Notes
pnpm run validateis red on 2 pre-existing oxlint errors inpackages/kilo-chat/src/utils.ts(no-non-null-assertion/no-unnecessary-type-assertiononbytes[i]!); not introduced here.packages/notifications/src/badge-buckets.tsupdated to describe the DO-storage layout instead of the dropped table.