Skip to content

feat: add batch stats endpoint support#1451

Merged
riderx merged 9 commits into
mainfrom
riderx/batch-stats-endpoint
Jan 18, 2026
Merged

feat: add batch stats endpoint support#1451
riderx merged 9 commits into
mainfrom
riderx/batch-stats-endpoint

Conversation

@riderx
Copy link
Copy Markdown
Member

@riderx riderx commented Jan 15, 2026

Summary

Enable the stats endpoint to accept both single events and arrays of events in a single request. Single events maintain backward compatibility with { status: 'ok' } responses, while batch requests return detailed per-event results including status and error details for each event.

Test plan

  • Verified batch processing with multiple events succeeds
  • Verified batch with partial failures continues processing remaining events
  • Verified empty batch returns empty results without acquiring DB connection
  • Verified single events still return simple response for backward compatibility
  • All existing stats tests continue to pass

Summary by CodeRabbit

  • New Features

    • Batch submissions for stats with per-item results, index tracking, empty-batch handling, and on-prem indicators
    • Device ID normalization and per-app / per-batch rate limiting
    • Structured, detailed success/error responses for each item
  • Bug Fixes

    • Enforced consistent app_id across batch items with clearer error reporting
  • Tests

    • Expanded tests for batch success/partial-failure, on-prem cases, concurrency, and single-item backward compatibility

✏️ Tip: You can customize this high-level summary in your review settings.


Note

Adds batch processing to POST /stats with per-item results while preserving single-event responses.

  • Accepts arrays of events; returns { status: 'ok', results: [...] } with per-item ok/error, index, and messages; empty arrays return results: []
  • Normalizes device_id to lowercase; validates app_id format and enforces a single app_id per batch; per-app rate limiting
  • Refactors internals to return structured results (on-prem, plan-cancelled, version-not-found) and maps them to response codes/messages; single events still return { status: 'ok' }
  • Adds comprehensive tests for batch success, partial failures, mixed app_ids rejection, on-prem apps, and same-device multi-action batches

Written by Cursor Bugbot for commit dd8fda8. This will update automatically on new commits. Configure here.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 15, 2026

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

📝 Walkthrough

Walkthrough

Adds batch processing to the stats endpoint, introduces structured PostResult/BatchStatsResult types, normalizes input via parseBodyRaw, validates uniform app_id and rate-limits batches, returns per-item results, and preserves single-event backward compatibility while updating tests for batch scenarios.

Changes

Cohort / File(s) Summary
Batch Processing Implementation
supabase/functions/_backend/plugins/stats.ts
Added exported BatchStatsResult and internal PostResult interfaces. Changed post() signature to return Promise<PostResult>. Added parseBodyRaw(c: Context) to normalize incoming JSON (lowercase device_id) and detect batch vs single. Refactored app.post to support batched input: empty-batch handling, uniform app_id validation, batch-wide rate limiting, per-item processing with indexed results, and structured aggregation. Replaced many direct simpleError200/BRES paths with structured PostResult propagation; adjusted imports to include simpleError.
Batch Testing Suite
tests/stats.test.ts
Added BatchStatsRes interface and comprehensive batch tests: all-success batch, partial failures, empty batch, single-event backward compatibility, same-device multiple actions, mixed app_id rejection, and on-prem app behavior. Tests validate per-item results and DB state; concurrency behavior varies by environment (Supabase vs Cloudflare).

Sequence Diagram

sequenceDiagram
    participant Client
    participant Handler as POST Handler
    participant Parser as parseBodyRaw
    participant PostFn as post()
    participant DB as Database

    Client->>Handler: POST /stats (single or batch)
    Handler->>Parser: parseBodyRaw(context)
    Parser->>Parser: Normalize JSON (lowercase device_id)
    Parser-->>Handler: AppStats | AppStats[]

    alt Batch Mode
        Handler->>Handler: Validate uniform app_id & apply batch rate limit
        loop per item (index)
            Handler->>PostFn: post(context, drizzleClient, item)
            PostFn->>DB: validate, lookup, insert/update
            PostFn-->>Handler: PostResult {success, error?, message?, isOnprem?, moreInfo?}
            Handler->>Handler: Accumulate BatchStatsResult (status, index, ...)
        end
        Handler-->>Client: {status: 'ok', results: BatchStatsResult[]}
    else Single Mode
        Handler->>PostFn: post(context, drizzleClient, item)
        PostFn->>DB: validate, lookup, insert/update
        PostFn-->>Handler: PostResult
        alt success
            Handler-->>Client: BRES (existing success response)
        else error
            Handler-->>Client: structured error via simpleError200
        end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hopped in with payloads, one and many,
Lowercased my whiskers, indexed each penny.
Batch by batch, I sorted each tale,
Success or hiccup, I left a trail.
Crunching stats with a joyful tail. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add batch stats endpoint support' clearly and accurately summarizes the main change—adding batch processing capability to the stats endpoint.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 356bcca13e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +192 to +195
// Rate limit check on first event's app_id (all events in batch should be from same app)
if (isLimited(c, events[0].app_id)) {
return simpleRateLimit({ app_id: events[0].app_id })
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce rate limits per app in batch requests

Batch requests only check isLimited for events[0].app_id, but nothing enforces that all events share the same app_id. If a client sends a batch containing multiple apps, only the first app is rate-limited and the rest bypass any LIMITED_APPS throttling, which can defeat per-app rate limits in production. Consider validating that all events share the same app_id, or apply isLimited per event/app.

Useful? React with 👍 / 👎.

Comment thread tests/stats.test.ts Outdated
Comment on lines +384 to +386
describe('[POST] /stats batch operations', () => {
it('should handle batch of events', async () => {
const uuid1 = randomUUID().toLowerCase()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use it.concurrent for new tests per AGENTS.md

Repository guidance in AGENTS.md (repo root) states: “Maximize parallelism: Use it.concurrent() instead of it() to run tests in parallel within the same file.” The newly added batch tests use it(...), which forces sequential execution and violates that requirement. Please switch these new tests to it.concurrent(...) to align with the mandated parallel test strategy.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@supabase/functions/_backend/plugins/stats.ts`:
- Around line 243-249: The single-event path currently uses simpleError200 which
forces HTTP 200 for validation failures; replace that call with quickError so
the response can carry the proper status code. In the block handling !isBatch
(where results[0] is inspected), call quickError(c, result.error!,
result.message!, result.statusCode ?? result.code ?? 400) instead of
simpleError200 so validation errors return their intended HTTP status (falling
back to 400 if the result has no explicit status field); keep the existing
c.json(BRES) for the 'ok' case.
- Around line 192-206: The batch-level mixed-app_ids check and the isLimited
call use firstAppId (events[0].app_id) before any schema validation; move
validation so firstAppId is guaranteed to be a validated string: run
jsonRequestSchema / parsePluginBody (or otherwise validate events[0].app_id)
prior to computing firstAppId and performing the for-loop and isLimited check,
or alternatively iterate through events and call parsePluginBody for each item
up-front to validate app_id types before calling simpleError200,
simpleRateLimit, or isLimited; ensure references to firstAppId, events, isBatch,
parsePluginBody, jsonRequestSchema, simpleError200, and isLimited are updated
accordingly.
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f0947b0 and 5dc76d0.

⛔ Files ignored due to path filters (1)
  • supabase/functions/deno.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • supabase/functions/_backend/plugins/stats.ts
  • tests/stats.test.ts
🧰 Additional context used
📓 Path-based instructions (9)
supabase/functions/_backend/**/*.{ts,js}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

supabase/functions/_backend/**/*.{ts,js}: Backend code must be placed in supabase/functions/_backend/ as shared code deployed to Cloudflare Workers (API/Plugin/Files workers), Supabase Edge Functions, and other platforms
Use createHono from utils/hono.ts for all Hono framework application initialization and routing
All database operations must use getPgClient() or getDrizzleClient() from utils/pg.ts for PostgreSQL access during active migration to Cloudflare D1
All Hono endpoint handlers must accept Context<MiddlewareKeyVariables> and use c.get('requestId'), c.get('apikey'), and c.get('auth') for request context
Use structured logging with cloudlog({ requestId: c.get('requestId'), message: '...' }) for all backend logging
Use middlewareAPISecret for internal API endpoints and middlewareKey for external API keys; validate against owner_org in the apikeys table
Check c.get('auth')?.authType to determine authentication type ('apikey' vs 'jwt') in backend endpoints
Use Drizzle ORM query patterns with schema from postgress_schema.ts for all database operations; use aliasV2() for self-joins or multiple table references

Files:

  • supabase/functions/_backend/plugins/stats.ts
supabase/functions/**/*.{ts,js}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Backend ESLint must pass before commit; run bun lint:backend for backend files

Files:

  • supabase/functions/_backend/plugins/stats.ts
**/*.{vue,ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Run bun lint to lint Vue, TypeScript, and JavaScript files; use bun lint:fix to auto-fix issues

Files:

  • supabase/functions/_backend/plugins/stats.ts
  • tests/stats.test.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use single quotes and no semicolons per @antfu/eslint-config rules

Files:

  • supabase/functions/_backend/plugins/stats.ts
  • tests/stats.test.ts
supabase/functions/**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

supabase/functions/**/*.ts: Never use the Supabase admin SDK with service key for user-facing APIs; always use client SDK with user authentication to enforce RLS policies. Admin SDK should only be used for internal operations (triggers, CRON jobs, etc.)
When admin access is unavoidable for a user-facing endpoint, sanitize all user inputs carefully to prevent PostgREST query injection

Files:

  • supabase/functions/_backend/plugins/stats.ts
**/*.{ts,tsx,js,jsx,vue}

📄 CodeRabbit inference engine (AGENTS.md)

Run bun lint or lint/format command before validating any backend or frontend task to ensure consistent formatting

Files:

  • supabase/functions/_backend/plugins/stats.ts
  • tests/stats.test.ts
supabase/functions/_backend/**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

Place core backend logic in supabase/functions/_backend/ with plugins, private, public, triggers, and utils subdirectories

Files:

  • supabase/functions/_backend/plugins/stats.ts
tests/**/*.{ts,js}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Backend tests must use helpers from tests/test-utils.ts including getEndpointUrl(path) for correct worker routing and USE_CLOUDFLARE_WORKERS=true for CF Workers testing

Files:

  • tests/stats.test.ts
tests/**/*.test.ts

📄 CodeRabbit inference engine (AGENTS.md)

tests/**/*.test.ts: ALL TEST FILES RUN IN PARALLEL - design tests accordingly with proper isolation and use it.concurrent() to maximize parallelism within the same file
Create dedicated seed data for tests that modify shared resources instead of reusing existing data; use unique naming conventions prefixed with test file name or feature being tested
Only reuse existing seed data if you only READ the data without modifying it, or create your OWN child resources under it without modifying the parent
Backend tests located in tests/ directory use Vitest test runner and require running Supabase instance

Files:

  • tests/stats.test.ts
🧠 Learnings (8)
📚 Learning: 2026-01-10T04:55:25.264Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-10T04:55:25.264Z
Learning: Applies to supabase/functions/_backend/**/*.ts : Place core backend logic in supabase/functions/_backend/ with plugins, private, public, triggers, and utils subdirectories

Applied to files:

  • supabase/functions/_backend/plugins/stats.ts
📚 Learning: 2025-12-23T02:53:12.055Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-12-23T02:53:12.055Z
Learning: Applies to supabase/functions/_backend/**/*.{ts,js} : Use `createHono` from `utils/hono.ts` for all Hono framework application initialization and routing

Applied to files:

  • supabase/functions/_backend/plugins/stats.ts
📚 Learning: 2025-12-23T02:53:12.055Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-12-23T02:53:12.055Z
Learning: Applies to cloudflare_workers/plugin/index.ts : Plugin Worker (port 8788) routes: `/updates`, `/channel_self`, `/stats`

Applied to files:

  • supabase/functions/_backend/plugins/stats.ts
📚 Learning: 2025-12-23T02:53:12.055Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-12-23T02:53:12.055Z
Learning: Applies to cloudflare_workers/api/index.ts : API Worker (port 8787) routes: `/bundle`, `/app`, `/device`, `/channel`, `/private/*`, `/triggers`

Applied to files:

  • supabase/functions/_backend/plugins/stats.ts
📚 Learning: 2026-01-10T04:55:25.264Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-10T04:55:25.264Z
Learning: Applies to supabase/seed.sql : Update supabase/seed.sql for new or evolved tests; keep fixtures focused on current behavior while leaving committed migrations unchanged

Applied to files:

  • tests/stats.test.ts
📚 Learning: 2026-01-10T04:55:25.264Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-10T04:55:25.264Z
Learning: Applies to tests/**/*.test.ts : Create dedicated seed data for tests that modify shared resources instead of reusing existing data; use unique naming conventions prefixed with test file name or feature being tested

Applied to files:

  • tests/stats.test.ts
📚 Learning: 2026-01-10T04:55:25.264Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-10T04:55:25.264Z
Learning: Applies to tests/**/*.test.ts : ALL TEST FILES RUN IN PARALLEL - design tests accordingly with proper isolation and use `it.concurrent()` to maximize parallelism within the same file

Applied to files:

  • tests/stats.test.ts
📚 Learning: 2026-01-10T04:55:25.264Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-10T04:55:25.264Z
Learning: Applies to tests/**/*.test.ts : Backend tests located in tests/ directory use Vitest test runner and require running Supabase instance

Applied to files:

  • tests/stats.test.ts
🧬 Code graph analysis (1)
tests/stats.test.ts (2)
tests/test-utils.ts (4)
  • APP_NAME_STATS (53-53)
  • getVersionFromAction (196-199)
  • createAppVersions (201-217)
  • getSupabaseClient (388-407)
supabase/functions/_backend/utils/version.ts (1)
  • version (1-1)
🔇 Additional comments (6)
supabase/functions/_backend/plugins/stats.ts (5)

9-9: Import update looks fine.


23-28: Clear result typing for batch and post flows.

These interfaces make the new response shapes and internal flow easier to reason about.

Also applies to: 61-66


68-99: PostResult wiring is consistent.

Early returns now map cleanly to the caller without losing error detail.

Also applies to: 120-120, 156-156


161-180: Good normalization of device_id for single and batch payloads.


187-190: Batch loop and empty-batch short‑circuit look solid.

Per-item try/catch preserves partial success while keeping the response shape stable.

Also applies to: 208-242, 252-254

tests/stats.test.ts (1)

374-598: Batch test coverage and conditional concurrency look good.

Nice validation of partial failures, empty batches, and backward-compatible single-event responses.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment thread supabase/functions/_backend/plugins/stats.ts Outdated
Comment on lines +243 to +249
// For single event, return simple response for backward compatibility
if (!isBatch) {
const result = results[0]
if (result.status === 'ok') {
return c.json(BRES)
}
return simpleError200(c, result.error!, result.message!)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

rg -n "simpleError200" supabase/functions/_backend/utils/hono.ts

Repository: Cap-go/capgo

Length of output: 160


🏁 Script executed:

sed -n '213,240p' supabase/functions/_backend/utils/hono.ts

Repository: Cap-go/capgo

Length of output: 943


Use appropriate error status codes for single-event validation errors.

The simpleError200 helper function hardcodes HTTP 200 for all errors. Using it for non-batch validation errors will return 200 instead of the likely 400 status codes these errors previously returned, breaking backward compatibility (e.g., existing invalid-action tests). Either confirm this status change is intentional, or use quickError (which accepts dynamic status codes) for single-event validation failures.

🤖 Prompt for AI Agents
In `@supabase/functions/_backend/plugins/stats.ts` around lines 243 - 249, The
single-event path currently uses simpleError200 which forces HTTP 200 for
validation failures; replace that call with quickError so the response can carry
the proper status code. In the block handling !isBatch (where results[0] is
inspected), call quickError(c, result.error!, result.message!, result.statusCode
?? result.code ?? 400) instead of simpleError200 so validation errors return
their intended HTTP status (falling back to 400 if the result has no explicit
status field); keep the existing c.json(BRES) for the 'ok' case.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@supabase/functions/_backend/plugins/stats.ts`:
- Around line 161-175: parseBodyRaw currently calls toLowerCase() on any truthy
device_id which throws for non-strings; update the normalization so you only
call toLowerCase when typeof device_id === "string" (both inside the
Array.isArray branch iterating items and in the single-body branch), leaving
non-string values untouched so schema validation can catch them; reference the
parseBodyRaw function and the AppStats.device_id field when making the change.
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5dc76d0 and da25392.

📒 Files selected for processing (1)
  • supabase/functions/_backend/plugins/stats.ts
🧰 Additional context used
📓 Path-based instructions (7)
supabase/functions/_backend/**/*.{ts,js}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

supabase/functions/_backend/**/*.{ts,js}: Backend code must be placed in supabase/functions/_backend/ as shared code deployed to Cloudflare Workers (API/Plugin/Files workers), Supabase Edge Functions, and other platforms
Use createHono from utils/hono.ts for all Hono framework application initialization and routing
All database operations must use getPgClient() or getDrizzleClient() from utils/pg.ts for PostgreSQL access during active migration to Cloudflare D1
All Hono endpoint handlers must accept Context<MiddlewareKeyVariables> and use c.get('requestId'), c.get('apikey'), and c.get('auth') for request context
Use structured logging with cloudlog({ requestId: c.get('requestId'), message: '...' }) for all backend logging
Use middlewareAPISecret for internal API endpoints and middlewareKey for external API keys; validate against owner_org in the apikeys table
Check c.get('auth')?.authType to determine authentication type ('apikey' vs 'jwt') in backend endpoints
Use Drizzle ORM query patterns with schema from postgress_schema.ts for all database operations; use aliasV2() for self-joins or multiple table references

Files:

  • supabase/functions/_backend/plugins/stats.ts
supabase/functions/**/*.{ts,js}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Backend ESLint must pass before commit; run bun lint:backend for backend files

Files:

  • supabase/functions/_backend/plugins/stats.ts
**/*.{vue,ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Run bun lint to lint Vue, TypeScript, and JavaScript files; use bun lint:fix to auto-fix issues

Files:

  • supabase/functions/_backend/plugins/stats.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use single quotes and no semicolons per @antfu/eslint-config rules

Files:

  • supabase/functions/_backend/plugins/stats.ts
supabase/functions/**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

supabase/functions/**/*.ts: Never use the Supabase admin SDK with service key for user-facing APIs; always use client SDK with user authentication to enforce RLS policies. Admin SDK should only be used for internal operations (triggers, CRON jobs, etc.)
When admin access is unavoidable for a user-facing endpoint, sanitize all user inputs carefully to prevent PostgREST query injection

Files:

  • supabase/functions/_backend/plugins/stats.ts
**/*.{ts,tsx,js,jsx,vue}

📄 CodeRabbit inference engine (AGENTS.md)

Run bun lint or lint/format command before validating any backend or frontend task to ensure consistent formatting

Files:

  • supabase/functions/_backend/plugins/stats.ts
supabase/functions/_backend/**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

Place core backend logic in supabase/functions/_backend/ with plugins, private, public, triggers, and utils subdirectories

Files:

  • supabase/functions/_backend/plugins/stats.ts
🧠 Learnings (5)
📚 Learning: 2026-01-10T04:55:25.264Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-10T04:55:25.264Z
Learning: Applies to supabase/functions/_backend/**/*.ts : Place core backend logic in supabase/functions/_backend/ with plugins, private, public, triggers, and utils subdirectories

Applied to files:

  • supabase/functions/_backend/plugins/stats.ts
📚 Learning: 2025-12-23T02:53:12.055Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-12-23T02:53:12.055Z
Learning: Applies to cloudflare_workers/plugin/index.ts : Plugin Worker (port 8788) routes: `/updates`, `/channel_self`, `/stats`

Applied to files:

  • supabase/functions/_backend/plugins/stats.ts
📚 Learning: 2025-12-23T02:53:12.055Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-12-23T02:53:12.055Z
Learning: Applies to supabase/functions/_backend/**/*.{ts,js} : Check `c.get('auth')?.authType` to determine authentication type ('apikey' vs 'jwt') in backend endpoints

Applied to files:

  • supabase/functions/_backend/plugins/stats.ts
📚 Learning: 2025-12-23T02:53:12.055Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-12-23T02:53:12.055Z
Learning: Applies to supabase/functions/_backend/**/*.{ts,js} : Use `createHono` from `utils/hono.ts` for all Hono framework application initialization and routing

Applied to files:

  • supabase/functions/_backend/plugins/stats.ts
📚 Learning: 2025-12-23T02:53:12.055Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-12-23T02:53:12.055Z
Learning: Applies to cloudflare_workers/api/index.ts : API Worker (port 8787) routes: `/bundle`, `/app`, `/device`, `/channel`, `/private/*`, `/triggers`

Applied to files:

  • supabase/functions/_backend/plugins/stats.ts
🧬 Code graph analysis (1)
supabase/functions/_backend/plugins/stats.ts (3)
supabase/functions/_backend/utils/types.ts (1)
  • AppStats (23-26)
supabase/functions/_backend/utils/plugin_parser.ts (1)
  • parsePluginBody (36-65)
supabase/functions/_backend/utils/hono.ts (5)
  • MiddlewareKeyVariables (27-41)
  • simpleError (247-249)
  • simpleError200 (213-222)
  • simpleRateLimit (239-245)
  • BRES (135-135)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Run tests
🔇 Additional comments (3)
supabase/functions/_backend/plugins/stats.ts (3)

9-9: Batch response typing looks solid.

The BatchStatsResult shape (plus the added helper import) makes the batch contract explicit and easier to consume.

Also applies to: 23-28


61-157: Structured PostResult flow is clear.

Returning a unified PostResult keeps the single vs. batch code paths consistent and readable.


182-268: Batch flow and early app_id checks look good.

The empty-batch short‑circuit and per-item aggregation align well with the intended batch behavior.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment thread supabase/functions/_backend/plugins/stats.ts
@riderx riderx force-pushed the riderx/batch-stats-endpoint branch 2 times, most recently from 31fc702 to 9824d3e Compare January 16, 2026 05:46
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@supabase/functions/_backend/plugins/stats.ts`:
- Around line 221-248: The handler currently ignores the isOnprem flag returned
by post(); before treating a post() success as OK, check result.isOnprem and for
single requests call onPremStats(c) (preserving the 429 behavior) instead of
returning BRES, and for batch requests mark that event's BatchStatsResult as an
error (status: 'error', include result.error and result.message or a canonical
on‑prem message) rather than pushing {status:'ok'}. Adjust the post() success
branch in both the single-path (where parsePluginBody/post/BRES are used) and
inside the batch loop (where results: BatchStatsResult[] is populated) to handle
isOnprem explicitly.
♻️ Duplicate comments (1)
supabase/functions/_backend/plugins/stats.ts (1)

165-174: Guard device_id normalization to strings only.

Calling toLowerCase() on non-strings throws and turns schema errors into invalid_json_parse_body. Please gate by typeof === 'string' for both single and batch items.

🔧 Proposed fix
     if (Array.isArray(body)) {
       for (const item of body) {
-        if (item.device_id) {
-          item.device_id = item.device_id.toLowerCase()
-        }
+        if (typeof item.device_id === 'string') {
+          item.device_id = item.device_id.toLowerCase()
+        }
       }
     }
-    else if (body.device_id) {
+    else if (typeof body.device_id === 'string') {
       body.device_id = body.device_id.toLowerCase()
     }
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between da25392 and 9824d3e.

📒 Files selected for processing (2)
  • supabase/functions/_backend/plugins/stats.ts
  • tests/stats.test.ts
🧰 Additional context used
📓 Path-based instructions (9)
tests/**/*.{ts,js}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Backend tests must use helpers from tests/test-utils.ts including getEndpointUrl(path) for correct worker routing and USE_CLOUDFLARE_WORKERS=true for CF Workers testing

Files:

  • tests/stats.test.ts
**/*.{vue,ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Run bun lint to lint Vue, TypeScript, and JavaScript files; use bun lint:fix to auto-fix issues

Files:

  • tests/stats.test.ts
  • supabase/functions/_backend/plugins/stats.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use single quotes and no semicolons per @antfu/eslint-config rules

Files:

  • tests/stats.test.ts
  • supabase/functions/_backend/plugins/stats.ts
tests/**/*.test.ts

📄 CodeRabbit inference engine (AGENTS.md)

tests/**/*.test.ts: ALL TEST FILES RUN IN PARALLEL - design tests accordingly with proper isolation and use it.concurrent() to maximize parallelism within the same file
Create dedicated seed data for tests that modify shared resources instead of reusing existing data; use unique naming conventions prefixed with test file name or feature being tested
Only reuse existing seed data if you only READ the data without modifying it, or create your OWN child resources under it without modifying the parent
Backend tests located in tests/ directory use Vitest test runner and require running Supabase instance

Files:

  • tests/stats.test.ts
**/*.{ts,tsx,js,jsx,vue}

📄 CodeRabbit inference engine (AGENTS.md)

Run bun lint or lint/format command before validating any backend or frontend task to ensure consistent formatting

Files:

  • tests/stats.test.ts
  • supabase/functions/_backend/plugins/stats.ts
supabase/functions/_backend/**/*.{ts,js}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

supabase/functions/_backend/**/*.{ts,js}: Backend code must be placed in supabase/functions/_backend/ as shared code deployed to Cloudflare Workers (API/Plugin/Files workers), Supabase Edge Functions, and other platforms
Use createHono from utils/hono.ts for all Hono framework application initialization and routing
All database operations must use getPgClient() or getDrizzleClient() from utils/pg.ts for PostgreSQL access during active migration to Cloudflare D1
All Hono endpoint handlers must accept Context<MiddlewareKeyVariables> and use c.get('requestId'), c.get('apikey'), and c.get('auth') for request context
Use structured logging with cloudlog({ requestId: c.get('requestId'), message: '...' }) for all backend logging
Use middlewareAPISecret for internal API endpoints and middlewareKey for external API keys; validate against owner_org in the apikeys table
Check c.get('auth')?.authType to determine authentication type ('apikey' vs 'jwt') in backend endpoints
Use Drizzle ORM query patterns with schema from postgress_schema.ts for all database operations; use aliasV2() for self-joins or multiple table references

Files:

  • supabase/functions/_backend/plugins/stats.ts
supabase/functions/**/*.{ts,js}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Backend ESLint must pass before commit; run bun lint:backend for backend files

Files:

  • supabase/functions/_backend/plugins/stats.ts
supabase/functions/**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

supabase/functions/**/*.ts: Never use the Supabase admin SDK with service key for user-facing APIs; always use client SDK with user authentication to enforce RLS policies. Admin SDK should only be used for internal operations (triggers, CRON jobs, etc.)
When admin access is unavoidable for a user-facing endpoint, sanitize all user inputs carefully to prevent PostgREST query injection

Files:

  • supabase/functions/_backend/plugins/stats.ts
supabase/functions/_backend/**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

Place core backend logic in supabase/functions/_backend/ with plugins, private, public, triggers, and utils subdirectories

Files:

  • supabase/functions/_backend/plugins/stats.ts
🧠 Learnings (8)
📚 Learning: 2026-01-10T04:55:25.264Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-10T04:55:25.264Z
Learning: Applies to supabase/seed.sql : Update supabase/seed.sql for new or evolved tests; keep fixtures focused on current behavior while leaving committed migrations unchanged

Applied to files:

  • tests/stats.test.ts
📚 Learning: 2026-01-10T04:55:25.264Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-10T04:55:25.264Z
Learning: Applies to tests/**/*.test.ts : Create dedicated seed data for tests that modify shared resources instead of reusing existing data; use unique naming conventions prefixed with test file name or feature being tested

Applied to files:

  • tests/stats.test.ts
📚 Learning: 2026-01-10T04:55:25.264Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-10T04:55:25.264Z
Learning: Applies to tests/**/*.test.ts : ALL TEST FILES RUN IN PARALLEL - design tests accordingly with proper isolation and use `it.concurrent()` to maximize parallelism within the same file

Applied to files:

  • tests/stats.test.ts
📚 Learning: 2026-01-10T04:55:25.264Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-10T04:55:25.264Z
Learning: Applies to supabase/functions/_backend/**/*.ts : Place core backend logic in supabase/functions/_backend/ with plugins, private, public, triggers, and utils subdirectories

Applied to files:

  • supabase/functions/_backend/plugins/stats.ts
📚 Learning: 2025-12-23T02:53:12.055Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-12-23T02:53:12.055Z
Learning: Applies to cloudflare_workers/plugin/index.ts : Plugin Worker (port 8788) routes: `/updates`, `/channel_self`, `/stats`

Applied to files:

  • supabase/functions/_backend/plugins/stats.ts
📚 Learning: 2025-12-23T02:53:12.055Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-12-23T02:53:12.055Z
Learning: Applies to supabase/functions/_backend/**/*.{ts,js} : Check `c.get('auth')?.authType` to determine authentication type ('apikey' vs 'jwt') in backend endpoints

Applied to files:

  • supabase/functions/_backend/plugins/stats.ts
📚 Learning: 2025-12-23T02:53:12.055Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-12-23T02:53:12.055Z
Learning: Applies to supabase/functions/_backend/**/*.{ts,js} : Use `createHono` from `utils/hono.ts` for all Hono framework application initialization and routing

Applied to files:

  • supabase/functions/_backend/plugins/stats.ts
📚 Learning: 2025-12-23T02:53:12.055Z
Learnt from: CR
Repo: Cap-go/capgo PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-12-23T02:53:12.055Z
Learning: Applies to cloudflare_workers/api/index.ts : API Worker (port 8787) routes: `/bundle`, `/app`, `/device`, `/channel`, `/private/*`, `/triggers`

Applied to files:

  • supabase/functions/_backend/plugins/stats.ts
🧬 Code graph analysis (1)
supabase/functions/_backend/plugins/stats.ts (9)
supabase/functions/_backend/utils/pg.ts (2)
  • getDrizzleClient (220-222)
  • getPgClient (187-218)
supabase/functions/_backend/utils/types.ts (1)
  • AppStats (23-26)
supabase/functions/_backend/utils/plugin_parser.ts (2)
  • makeDevice (17-34)
  • parsePluginBody (36-65)
supabase/functions/_backend/utils/appStatus.ts (2)
  • getAppStatus (20-28)
  • setAppStatus (30-37)
supabase/functions/_backend/utils/stats.ts (1)
  • onPremStats (19-41)
supabase/functions/_backend/triggers/on_version_update.ts (1)
  • app (279-279)
supabase/functions/_backend/triggers/on_app_create.ts (1)
  • app (12-12)
supabase/functions/_backend/triggers/cron_email.ts (1)
  • app (93-93)
supabase/functions/_backend/utils/hono.ts (5)
  • MiddlewareKeyVariables (27-41)
  • simpleError (247-249)
  • simpleError200 (213-222)
  • simpleRateLimit (239-245)
  • BRES (135-135)
🪛 GitHub Actions: Run tests
tests/stats.test.ts

[error] 226-226: Test failed during 'bun run test:all'. AssertionError: expected 200 to be 429.

🔇 Additional comments (3)
supabase/functions/_backend/plugins/stats.ts (2)

9-9: Import-only change.


23-28: BatchStatsResult looks good.

tests/stats.test.ts (1)

374-597: Batch test coverage and CF gating look solid.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment thread supabase/functions/_backend/plugins/stats.ts
Copilot AI review requested due to automatic review settings January 17, 2026 01:28
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR enables the stats endpoint to accept both single events and arrays of events in a single request. Single events maintain backward compatibility with simple { status: 'ok' } responses, while batch requests return detailed per-event results including status and error details. The implementation validates that all events in a batch share the same app_id to prevent rate limit bypass, handles partial failures gracefully by continuing to process remaining events, and returns early for empty batches.

Changes:

  • Refactored the post function to return a PostResult object instead of directly returning HTTP responses
  • Added parseBodyRaw function to handle both single and array payloads with device_id normalization
  • Implemented batch processing logic that collects per-event results and handles errors individually
  • Added comprehensive test coverage for batch operations including success, partial failures, empty batches, and backward compatibility

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 7 comments.

File Description
supabase/functions/_backend/plugins/stats.ts Refactored stats endpoint to support batch processing while maintaining backward compatibility; added validation for same app_id across batch events
tests/stats.test.ts Added comprehensive test suite for batch operations covering success cases, partial failures, empty batches, mixed app_ids, and backward compatibility
supabase/functions/_backend/utils/supabase.ts Modified expiration date validation to accept dates equal to current time (changed from <= to <)

Comment on lines +75 to +76
await onPremStats(c, app_id, action, device)
return { success: true, isOnprem: true }
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The onPremStats function returns a Response object with status 429, but this return value is being ignored. In the original code, the response was returned to the client. Now the function is awaited and its return value is discarded, while a success result is returned instead. This means batch operations with onprem apps will incorrectly report success status instead of the appropriate 429 error that onPremStats is designed to return.

Copilot uses AI. Check for mistakes.
Comment on lines +85 to +86
await onPremStats(c, app_id, action, device)
return { success: true, isOnprem: true }
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The onPremStats function returns a Response object with status 429, but this return value is being ignored. In the original code, the response was returned to the client. Now the function is awaited and its return value is discarded, while a success result is returned instead. This means batch operations with onprem apps will incorrectly report success status instead of the appropriate 429 error that onPremStats is designed to return.

Copilot uses AI. Check for mistakes.
// Validate all events in batch have the same app_id to prevent rate limit bypass
if (isBatch) {
for (let i = 1; i < events.length; i++) {
if (events[i].app_id !== firstAppId) {
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The batch validation check only validates that events[i].app_id exists and is a string, but doesn't validate it against the same regex pattern (reverseDomainRegex) that's used for the first event. This could allow invalid app_ids to pass validation for events after the first one in a batch.

Suggested change
if (events[i].app_id !== firstAppId) {
const currentAppId = events[i].app_id
// Ensure app_id exists and is a valid reverse-domain string for every event
if (!currentAppId || typeof currentAppId !== 'string') {
throw simpleError('invalid_app_id', MISSING_STRING_APP_ID)
}
if (!reverseDomainRegex.test(currentAppId)) {
throw simpleError('invalid_app_id', INVALID_STRING_APP_ID)
}
if (currentAppId !== firstAppId) {

Copilot uses AI. Check for mistakes.
Comment on lines +161 to +180
async function parseBodyRaw(c: Context): Promise<AppStats | AppStats[]> {
try {
const body = await c.req.json<AppStats | AppStats[]>()
// Normalize device_id to lowercase for both single and array
if (Array.isArray(body)) {
for (const item of body) {
if (item.device_id) {
item.device_id = item.device_id.toLowerCase()
}
}
}
else if (body.device_id) {
body.device_id = body.device_id.toLowerCase()
}
return body
}
catch (e) {
throw simpleError('invalid_json_parse_body', 'Invalid JSON body', { e })
}
}
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The custom parseBodyRaw function duplicates logic from the existing parseBody utility function (which also normalizes device_id to lowercase). This creates maintenance burden and potential for inconsistencies. Consider extending or reusing the existing parseBody function to handle arrays instead of creating a separate implementation.

Copilot uses AI. Check for mistakes.
// Validate all events in batch have the same app_id to prevent rate limit bypass
if (isBatch) {
for (let i = 1; i < events.length; i++) {
if (events[i].app_id !== firstAppId) {
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation logic checks if events[i].app_id is defined and is a string type, but this check is redundant since the next line directly compares events[i].app_id !== firstAppId. If events[i].app_id is undefined or not a string, the comparison would still work (undefined !== 'somestring' is true). The validation should either be removed as redundant, or if null/undefined app_ids need special handling, that should be explicit.

Suggested change
if (events[i].app_id !== firstAppId) {
const currentAppId = events[i].app_id
// Ensure each event has a valid string app_id in reverse-domain format
if (!currentAppId || typeof currentAppId !== 'string') {
return simpleError200(c, 'invalid_app_id', MISSING_STRING_APP_ID)
}
if (!reverseDomainRegex.test(currentAppId)) {
return simpleError200(c, 'invalid_app_id', INVALID_STRING_APP_ID)
}
if (currentAppId !== firstAppId) {

Copilot uses AI. Check for mistakes.
throw simpleError('invalid_expiration_date', 'Invalid expiration date format')
}
if (expirationDate <= new Date()) {
if (expirationDate < new Date()) {
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change modifies the expiration date validation logic to accept dates exactly equal to the current time, whereas previously they were rejected. While this might be intentional, it appears unrelated to the PR's stated purpose of adding batch stats endpoint support. Consider whether this change should be in a separate PR, especially since it affects API key management functionality across the codebase.

Suggested change
if (expirationDate < new Date()) {
const now = new Date()
if (expirationDate <= now) {

Copilot uses AI. Check for mistakes.
Comment thread tests/stats.test.ts
Comment on lines +388 to +598
batchTestDescribe('[POST] /stats batch operations', () => {
batchTestIt('should handle batch of events', async () => {
const uuid1 = randomUUID().toLowerCase()
const uuid2 = randomUUID().toLowerCase()

const baseData1 = getBaseData(APP_NAME_STATS) as StatsPayload
baseData1.device_id = uuid1
baseData1.action = 'get'
baseData1.version_build = getVersionFromAction('get')
const version1 = await createAppVersions(baseData1.version_build, APP_NAME_STATS)
baseData1.version_name = version1.name

const baseData2 = getBaseData(APP_NAME_STATS) as StatsPayload
baseData2.device_id = uuid2
baseData2.action = 'set'
baseData2.version_build = getVersionFromAction('set')
const version2 = await createAppVersions(baseData2.version_build, APP_NAME_STATS)
baseData2.version_name = version2.name

// Send batch request
const response = await postStats([baseData1, baseData2])
expect(response.status).toBe(200)

const responseData = await response.json<BatchStatsRes>()
expect(responseData.status).toBe('ok')
expect(responseData.results).toBeDefined()
expect(responseData.results).toHaveLength(2)
expect(responseData.results![0].status).toBe('ok')
expect(responseData.results![0].index).toBe(0)
expect(responseData.results![1].status).toBe('ok')
expect(responseData.results![1].index).toBe(1)

// Verify both stats entries were created
const { error: statsError1, data: statsData1 } = await getSupabaseClient()
.from('stats')
.select()
.eq('device_id', uuid1)
.eq('app_id', APP_NAME_STATS)
.eq('action', 'get')
.single()
expect(statsError1).toBeNull()
expect(statsData1).toBeTruthy()

const { error: statsError2, data: statsData2 } = await getSupabaseClient()
.from('stats')
.select()
.eq('device_id', uuid2)
.eq('app_id', APP_NAME_STATS)
.eq('action', 'set')
.single()
expect(statsError2).toBeNull()
expect(statsData2).toBeTruthy()

// Clean up
await getSupabaseClient().from('devices').delete().eq('device_id', uuid1).eq('app_id', APP_NAME_STATS)
await getSupabaseClient().from('devices').delete().eq('device_id', uuid2).eq('app_id', APP_NAME_STATS)
})

batchTestIt('should handle batch with partial failures', async () => {
const uuid1 = randomUUID().toLowerCase()
const uuid2 = randomUUID().toLowerCase()

const baseData1 = getBaseData(APP_NAME_STATS) as StatsPayload
baseData1.device_id = uuid1
baseData1.action = 'get'
baseData1.version_build = getVersionFromAction('get')
const version1 = await createAppVersions(baseData1.version_build, APP_NAME_STATS)
baseData1.version_name = version1.name

// Second event has invalid action
const baseData2 = getBaseData(APP_NAME_STATS) as StatsPayload
baseData2.device_id = uuid2
baseData2.action = 'invalid_action_xyz' as StatsAction
baseData2.version_build = getVersionFromAction('set')

// Send batch request
const response = await postStats([baseData1, baseData2])
expect(response.status).toBe(200)

const responseData = await response.json<BatchStatsRes>()
expect(responseData.status).toBe('ok')
expect(responseData.results).toBeDefined()
expect(responseData.results).toHaveLength(2)

// First event should succeed
expect(responseData.results![0].status).toBe('ok')
expect(responseData.results![0].index).toBe(0)

// Second event should fail
expect(responseData.results![1].status).toBe('error')
expect(responseData.results![1].index).toBe(1)
expect(responseData.results![1].error).toBeTruthy()

// Verify first stats entry was created
const { error: statsError1, data: statsData1 } = await getSupabaseClient()
.from('stats')
.select()
.eq('device_id', uuid1)
.eq('app_id', APP_NAME_STATS)
.eq('action', 'get')
.single()
expect(statsError1).toBeNull()
expect(statsData1).toBeTruthy()

// Clean up
await getSupabaseClient().from('devices').delete().eq('device_id', uuid1).eq('app_id', APP_NAME_STATS)
})

batchTestIt('should handle empty batch', async () => {
const response = await postStats([])
expect(response.status).toBe(200)

const responseData = await response.json<BatchStatsRes>()
expect(responseData.status).toBe('ok')
expect(responseData.results).toBeDefined()
expect(responseData.results).toHaveLength(0)
})

batchTestIt('single event should still return simple response for backward compatibility', async () => {
const uuid = randomUUID().toLowerCase()
const baseData = getBaseData(APP_NAME_STATS) as StatsPayload
baseData.device_id = uuid
baseData.action = 'get'
baseData.version_build = getVersionFromAction('get')
const version = await createAppVersions(baseData.version_build, APP_NAME_STATS)
baseData.version_name = version.name

// Send single event (not in array)
const response = await postStats(baseData)
expect(response.status).toBe(200)

const responseData = await response.json<StatsRes>()
// Should return simple { status: 'ok' } not batch format
expect(responseData.status).toBe('ok')
expect((responseData as BatchStatsRes).results).toBeUndefined()

// Clean up
await getSupabaseClient().from('devices').delete().eq('device_id', uuid).eq('app_id', APP_NAME_STATS)
})

batchTestIt('should handle batch with same device multiple actions', async () => {
const uuid = randomUUID().toLowerCase()

const baseData1 = getBaseData(APP_NAME_STATS) as StatsPayload
baseData1.device_id = uuid
baseData1.action = 'get'
baseData1.version_build = getVersionFromAction('get')
const version1 = await createAppVersions(baseData1.version_build, APP_NAME_STATS)
baseData1.version_name = version1.name

const baseData2 = { ...baseData1 }
baseData2.action = 'set'
baseData2.version_build = getVersionFromAction('set')
const version2 = await createAppVersions(baseData2.version_build, APP_NAME_STATS)
baseData2.version_name = version2.name

// Send batch with same device, different actions
const response = await postStats([baseData1, baseData2])
expect(response.status).toBe(200)

const responseData = await response.json<BatchStatsRes>()
expect(responseData.status).toBe('ok')
expect(responseData.results).toHaveLength(2)
expect(responseData.results![0].status).toBe('ok')
expect(responseData.results![1].status).toBe('ok')

// Verify both stats entries were created
const { error: statsError, data: statsData } = await getSupabaseClient()
.from('stats')
.select()
.eq('device_id', uuid)
.eq('app_id', APP_NAME_STATS)
.order('created_at', { ascending: true })
expect(statsError).toBeNull()
expect(statsData).toBeTruthy()
expect(statsData).toHaveLength(2)
expect(statsData![0].action).toBe('get')
expect(statsData![1].action).toBe('set')

// Clean up
await getSupabaseClient().from('devices').delete().eq('device_id', uuid).eq('app_id', APP_NAME_STATS)
})

batchTestIt('should reject batch with mixed app_ids', async () => {
const uuid1 = randomUUID().toLowerCase()
const uuid2 = randomUUID().toLowerCase()

const baseData1 = getBaseData(APP_NAME_STATS) as StatsPayload
baseData1.device_id = uuid1
baseData1.action = 'get'
baseData1.version_build = getVersionFromAction('get')
const version1 = await createAppVersions(baseData1.version_build, APP_NAME_STATS)
baseData1.version_name = version1.name

// Second event has different app_id
const baseData2 = getBaseData(APP_NAME_STATS) as StatsPayload
baseData2.device_id = uuid2
baseData2.app_id = 'com.different.app'
baseData2.action = 'get'
baseData2.version_build = getVersionFromAction('get')
baseData2.version_name = version1.name

// Send batch request with mixed app_ids
const response = await postStats([baseData1, baseData2])
expect(response.status).toBe(200)

const responseData = await response.json<StatsRes>()
expect(responseData.error).toBe('mixed_app_ids')
expect(responseData.message).toBe('All events in a batch must have the same app_id')
})
})
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new batch functionality lacks test coverage for onprem apps in batch requests. Given that the onPremStats function is supposed to return a 429 status code (as verified in the existing single-event test "app that does not exist"), but the refactored code doesn't properly handle this return value in batch mode, this missing test coverage masks the bug where batch requests with onprem apps would incorrectly return success.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

This PR is being reviewed by Cursor Bugbot

Details

You are on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle.

To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

throw simpleError('invalid_expiration_date', 'Invalid expiration date format')
}
if (expirationDate <= new Date()) {
if (expirationDate < new Date()) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expiration date validation contradicts error message

Low Severity

The condition was changed from <= to <, which now allows expiration dates that are exactly equal to the current time. However, the error message still says "Expiration date must be in the future". A date that equals "now" is not "in the future", making the error message inconsistent with the actual validation logic. This could allow creating API keys with expiration times that are essentially immediate, which is likely not the intended behavior.

Fix in Cursor Fix in Web

@riderx riderx force-pushed the riderx/batch-stats-endpoint branch from 2a4b92c to 1c47507 Compare January 17, 2026 22:12
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

Comment thread supabase/functions/_backend/plugins/stats.ts Outdated
@riderx riderx force-pushed the riderx/batch-stats-endpoint branch from b7a818a to 87083ad Compare January 17, 2026 23:38
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@supabase/functions/_backend/plugins/stats.ts`:
- Around line 163-176: In parseBodyRaw, avoid accessing item.device_id on
non-object array elements—update the array normalization loop to first check
that each item is a non-null object (e.g., item && typeof item === 'object')
before testing item.device_id and lowercasing; do the same protective check for
the single-body branch (ensure body is a non-null object) so primitives/null in
the payload don't cause a thrown exception and allow per-item validation to
report errors instead.
- Around line 194-218: The code currently assumes events entries are objects and
does events[i].app_id directly; add guards to ensure each batch entry is a
non-null object before accessing app_id (e.g., check typeof events[i] ===
'object' && events[i] !== null) both for the initial firstEvent handling and
inside the isBatch loop, and if the check fails return the same structured
validation response via simpleError200(c, 'invalid_app_id',
MISSING_STRING_APP_ID) (keep using reverseDomainRegex for format validation and
preserve the current mixed_app_ids check against firstAppId).

Comment thread supabase/functions/_backend/plugins/stats.ts
Comment thread supabase/functions/_backend/plugins/stats.ts
riderx and others added 9 commits January 18, 2026 00:25
Enable stats endpoint to accept both single events and arrays of events, allowing clients to send multiple events in a single request. Single events maintain backward compatibility with simple { status: 'ok' } responses, while batch requests return detailed results per event including status and error details.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Validate all events in batch have same app_id to prevent rate limit bypass
- Update batch tests to use it.concurrent for parallel execution per AGENTS.md
- Add test for mixed app_id rejection

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Validate first event's app_id early before batch/rate-limit checks
- Fix single-event error handling to propagate proper 400 status codes

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Guard device_id normalization to only call toLowerCase on strings
- Handle isOnprem flag properly and return 429 for single requests,
  error status in batch results for on-premise apps
- Add regex validation for all batch event app_ids, not just the first
- Add test coverage for onprem apps in batch requests
- Revert unrelated supabase.ts expiration date validation change

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add moreInfo field to PostResult and BatchStatsResult interfaces
to pass debugging information (app_id, version_name) through to
error responses, maintaining backward compatibility.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add object type checks before accessing device_id in parseBodyRaw
- Use optional chaining for app_id access to handle null/primitive items
- Allows per-item validation errors instead of throwing on malformed input

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@riderx riderx force-pushed the riderx/batch-stats-endpoint branch from 9c25a80 to dd8fda8 Compare January 18, 2026 00:25
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@supabase/functions/_backend/plugins/stats.ts`:
- Around line 230-231: getPgClient(c, true) may return null but the code calls
getDrizzleClient(pgClient!) unconditionally; add an explicit null check on the
pgClient variable after the call (check pgClient === null or falsy) and handle
the failure path before calling getDrizzleClient: e.g., log/return an error
response or throw so getDrizzleClient is never invoked with a null value; update
the code around the pgClient/getDrizzleClient usage to only create drizzleClient
when pgClient is valid and ensure subsequent logic accounts for the
early-return/failure branch.

In `@tests/stats.test.ts`:
- Around line 560-565: The test is flaky because it assumes deterministic
ordering by created_at; update the assertion to avoid relying solely on
timestamps by either adding a secondary sort on a stable column (e.g., include
.order('index', { ascending: true }) or similar) in the query that returns
statsData or, better, map results using the batch/insert response index (use the
batch response's index values to correlate each stats row to its original
operation) and assert actions by that mapped index instead of positional order;
adjust the expectations that reference statsData[0]/statsData[1] to reference
rows by their resolved index or by the deterministic sort key so the test
becomes stable.

Comment on lines 230 to +231
const pgClient = getPgClient(c, true)
const drizzleClient = getDrizzleClient(pgClient!)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against null pgClient before creating Drizzle client.

getPgClient(c, true) can return null on failure, but line 231 passes it to getDrizzleClient with a non-null assertion. If getPgClient fails, this will cause a runtime error. Add an explicit null check.

🔧 Proposed fix
   const pgClient = getPgClient(c, true)
+  if (!pgClient) {
+    throw simpleError('db_connection_error', 'Failed to establish database connection')
+  }
-  const drizzleClient = getDrizzleClient(pgClient!)
+  const drizzleClient = getDrizzleClient(pgClient)
🤖 Prompt for AI Agents
In `@supabase/functions/_backend/plugins/stats.ts` around lines 230 - 231,
getPgClient(c, true) may return null but the code calls
getDrizzleClient(pgClient!) unconditionally; add an explicit null check on the
pgClient variable after the call (check pgClient === null or falsy) and handle
the failure path before calling getDrizzleClient: e.g., log/return an error
response or throw so getDrizzleClient is never invoked with a null value; update
the code around the pgClient/getDrizzleClient usage to only create drizzleClient
when pgClient is valid and ensure subsequent logic accounts for the
early-return/failure branch.

Comment thread tests/stats.test.ts
Comment on lines +560 to +565
.order('created_at', { ascending: true })
expect(statsError).toBeNull()
expect(statsData).toBeTruthy()
expect(statsData).toHaveLength(2)
expect(statsData![0].action).toBe('get')
expect(statsData![1].action).toBe('set')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential test flakiness due to timestamp ordering.

The assertion relies on created_at ordering to verify action sequence ('get' then 'set'). If the database timestamp precision is low (e.g., seconds) and both stats entries are inserted within the same time unit, the order may be non-deterministic, causing intermittent test failures.

Consider adding a secondary sort key or using index from the batch response to correlate results instead of relying on timestamp ordering.

Suggested fix using explicit index tracking
     // Verify both stats entries were created
     const { error: statsError, data: statsData } = await getSupabaseClient()
       .from('stats')
       .select()
       .eq('device_id', uuid)
       .eq('app_id', APP_NAME_STATS)
-      .order('created_at', { ascending: true })
     expect(statsError).toBeNull()
     expect(statsData).toBeTruthy()
     expect(statsData).toHaveLength(2)
-    expect(statsData![0].action).toBe('get')
-    expect(statsData![1].action).toBe('set')
+    // Verify both actions exist without relying on order
+    const actions = statsData!.map(s => s.action).sort()
+    expect(actions).toEqual(['get', 'set'])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.order('created_at', { ascending: true })
expect(statsError).toBeNull()
expect(statsData).toBeTruthy()
expect(statsData).toHaveLength(2)
expect(statsData![0].action).toBe('get')
expect(statsData![1].action).toBe('set')
expect(statsError).toBeNull()
expect(statsData).toBeTruthy()
expect(statsData).toHaveLength(2)
// Verify both actions exist without relying on order
const actions = statsData!.map(s => s.action).sort()
expect(actions).toEqual(['get', 'set'])
🤖 Prompt for AI Agents
In `@tests/stats.test.ts` around lines 560 - 565, The test is flaky because it
assumes deterministic ordering by created_at; update the assertion to avoid
relying solely on timestamps by either adding a secondary sort on a stable
column (e.g., include .order('index', { ascending: true }) or similar) in the
query that returns statsData or, better, map results using the batch/insert
response index (use the batch response's index values to correlate each stats
row to its original operation) and assert actions by that mapped index instead
of positional order; adjust the expectations that reference
statsData[0]/statsData[1] to reference rows by their resolved index or by the
deterministic sort key so the test becomes stable.

@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

}
if (!reverseDomainRegex.test(firstAppId)) {
throw simpleError('invalid_app_id', INVALID_STRING_APP_ID)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent HTTP status for batch app_id validation errors

Medium Severity

Batch requests with the same validation error return different HTTP status codes depending on position. If the first event has an invalid app_id, simpleError is thrown (returning 400). If a later event has an invalid app_id, simpleError200 is returned (returning 200 with error body). This inconsistency means clients cannot reliably determine the error type from the status code, and the same malformed data produces different response formats based on array position.

Additional Locations (1)

Fix in Cursor Fix in Web

@riderx riderx merged commit 9445364 into main Jan 18, 2026
12 checks passed
@riderx riderx deleted the riderx/batch-stats-endpoint branch January 18, 2026 00:41
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.

3 participants