Skip to content

feat(kit): async product sync with job queue, dry-run, and purge-local#127

Merged
hyochan merged 3 commits into
mainfrom
feat/kit-async-product-sync
May 5, 2026
Merged

feat(kit): async product sync with job queue, dry-run, and purge-local#127
hyochan merged 3 commits into
mainfrom
feat/kit-async-product-sync

Conversation

@hyochan

@hyochan hyochan commented May 5, 2026

Copy link
Copy Markdown
Member

Summary

  • Async background-job model for kit's product sync (replaces synchronous pushSyncProducts* actions that iOS Safari aborted as TypeError: Load failed on long catalogs — Sentry 486194d4, fix for @LukasB-DEV's report on PR #124).
  • New productSyncJobs table + enqueueProductSync mutation that returns immediately, workers (runProductSyncIOS / runProductSyncAndroid / runProductSyncPurgeLocal) write progress + result back to the row, dashboard subscribes reactively. Cancel + dismiss + reaper + pruner all wired up.
  • direction: "purge-local" resets kit's local catalog without touching upstream stores; confirm dialog warns about lost unpushed edits and permanently-deleted Draft rows.
  • Android dryRun parity with iOS — all four PUSH paths return plannedWrites so operators can preview Play changes the same way they preview ASC changes.
  • New reusable Tooltip component (opaque surfaces fix bg-popover transparency); HorizonCatalogNotice banner explains Meta's catalog-API absence; Toaster closeButton enables toast dismissal.
  • Sentry beforeSend tags Convex reconnect Load failed events with source: convex-reconnect + stable fingerprint to downsample noise.
  • Bugfix: files/internal.ts readFileAsBase64 was using Buffer in a V8 isolate runtime where it isn't a global, throwing ReferenceError: Buffer is not defined on every download. Replaced with chunked btoa(String.fromCharCode(...)).

Surface map (HTTP)

POST /v1/products/{apiKey}/sync/{ios|android}             → 202 { jobId, deduped }
POST /v1/products/{apiKey}/sync/{ios|android}?dryRun=true → 202 { jobId, deduped }
POST /v1/products/{apiKey}/sync/{ios|android}?direction=purge-local
GET  /v1/products/{apiKey}/sync/jobs/{jobId}              → { status, progress, result?, error? }
POST /v1/products/{apiKey}/sync/jobs/{jobId}/cancel       → { ok }

Schema

productSyncJobs:

{ projectId, platform: "IOS"|"Android",
  direction: "pull"|"push"|"both"|"purge-local",
  status: "queued"|"running"|"succeeded"|"failed",
  progress: { phase, current?, total?, failuresCount? },
  result?: { pulled, pushed, deleted?, failures, failuresTruncated?, plannedWrites? },
  error?, cancelRequested?, expectedDeadline?, createdBy?,
  startedAt?, completedAt?, createdAt }

Indexes: (projectId, platform, status) for active-job lookup, (projectId, createdAt) for dashboard, (status, expectedDeadline) for reaper, (status, completedAt) for pruner.

Crons

  • reapStaleProductSyncJobs (5 min) — flips running rows past expectedDeadline + 1min grace to failed
  • pruneProductSyncJobs (6 h) — succeeded > 7d, failed > 30d → deleted

Test plan

  • bun run --filter @hyodotdev/openiap-kit typecheck — 0 errors
  • bun run --filter @hyodotdev/openiap-kit lint — 0 issues
  • bun run --filter @hyodotdev/openiap-kit test — 346/346 (5 new in jobs.test.ts)
  • bun run --filter @hyodotdev/openiap-kit format:check — clean
  • bun run --filter @hyodotdev/openiap-kit smoke:server — green
  • Pre-commit gate — green
  • After merge: verify dashboard sync flow on staging with martie project (iOS + Android catalog, dry-run + real run + purge-local + cancel mid-run)
  • After merge: confirm Sentry no longer surfaces TypeError: Load failed from /api/action for kit dashboard sessions

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Product sync now operates asynchronously with job status polling and 202 responses.
    • Added ability to cancel active sync jobs.
    • Introduced dry-run preview for planned sync operations.
    • Added local catalog reset functionality.
    • Enhanced UI with reactive job status tracking and detailed progress reporting.
  • Documentation

    • Updated product sync documentation with async behavior and polling details.
  • Bug Fixes

    • Improved stale job cleanup and error handling for long-running syncs.

Replace the synchronous push-sync actions with a background-job
model. The previous public actions held the dashboard's HTTP fetch
open for the entire ASC / Play catalog walk, which iOS Safari
aborted on cellular and backgrounded tabs as
`TypeError: Load failed` (Sentry 486194d4, PR #124 follow-up from
LukasB-DEV's report).

- New `productSyncJobs` table stores queued/running/succeeded/failed
  state plus `progress.phase`, `result`, `error`, `cancelRequested`,
  `expectedDeadline`. Indexes back the dashboard's reactive
  `getActiveSyncJob` query, the reaper, and the pruner.
- `enqueueProductSync` mutation returns immediately with
  `{ jobId, deduped }`; idempotent on (project, platform). Schedules
  the appropriate worker via `ctx.scheduler.runAfter`.
- `runProductSyncIOS` / `runProductSyncAndroid` internalActions
  drive the existing pull/push pipelines with phase-boundary
  cancel checks (PULL.iaps -> PULL.subscriptions -> PUSH.drafts).
- New `direction: "purge-local"` empties kit's local catalog for a
  platform without touching the upstream store. Confirm dialog
  warns about lost unpushed edits and permanently-deleted Draft
  rows before proceeding.
- Android `performAndroidSync` now supports dryRun across all four
  PUSH paths (subscription patch / IAP patch / subscription
  create+activate / IAP insert), returning `plannedWrites` so
  operators can preview Play Console changes the same way they
  already could for App Store Connect.
- New `Tooltip` component replaces the inline hover popover with a
  reusable component; explicit opaque surfaces fix transparency
  that let table content bleed through.
- `HorizonCatalogNotice` banner surfaces the Meta API constraint
  (no catalog REST endpoint) so Horizon-enabled projects don't
  look for a missing sync button.
- `Toaster closeButton` lets operators dismiss notifications.
- Sentry `beforeSend` tags `TypeError: Load failed` events from
  Convex reconnects with `source: convex-reconnect` and a stable
  fingerprint so triage can downsample the noise.
- `reapStaleProductSyncJobs` cron flips workers stuck past the
  9-min deadline to failed; `pruneProductSyncJobs` cron drops
  succeeded rows after 7d / failed after 30d.
- Fix: `files/internal.ts` `readFileAsBase64` was using `Buffer`
  in a V8 isolate runtime where it isn't a global, throwing
  `ReferenceError: Buffer is not defined` on every download.
  Replaced with chunked `btoa(String.fromCharCode(...))`.
- Docs: kit-backend page documents async polling + cancel
  endpoints; CONVENTION.md adds a "Long-running operations"
  section documenting the pattern for future workers.

Tests: jobs.test.ts covers truncateFailures + retention-constant
invariants. 346/346 vitest, typecheck/lint/format clean,
smoke:server green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hyochan hyochan added 🐇 server 🏟️ ui 🎯 feature New feature 🐛 bug Something isn't working labels May 5, 2026
@hyochan

hyochan commented May 5, 2026

Copy link
Copy Markdown
Member Author

/gemini review

@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@hyochan has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 7 minutes and 58 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3e7b9270-3a91-460f-8edc-c59f88d70bb2

📥 Commits

Reviewing files that changed from the base of the PR and between 83d18fc and 3c9421f.

📒 Files selected for processing (12)
  • packages/kit/convex/files/internal.ts
  • packages/kit/convex/products/asc.ts
  • packages/kit/convex/products/jobs.ts
  • packages/kit/convex/products/play.ts
  • packages/kit/convex/products/sync.ts
  • packages/kit/convex/schema.ts
  • packages/kit/server/api/v1/products.ts
  • packages/kit/src/components/AuthModal/index.tsx
  • packages/kit/src/components/Modal.tsx
  • packages/kit/src/components/Tooltip.tsx
  • packages/kit/src/lib/sentry.ts
  • packages/kit/src/pages/auth/organization/project/products.tsx
📝 Walkthrough

Walkthrough

A new long-running product sync architecture replaces direct synchronous sync actions with asynchronous Convex Jobs that enqueue background workers, report progress, support cancellation, and persist results. HTTP endpoints return 202 with job IDs; clients poll for status. Workers for iOS (ASC) and Android (Google Play) now run as internal actions governed by job state. A UI overhaul reflects this model through reactive job querying and controls.

Changes

Long-Running Product Sync Jobs

Layer / File(s) Summary
Schema & Data Model
packages/kit/convex/schema.ts
New productSyncJobs table with job state (queued/running/succeeded/failed), progress tracking, results (pulled/pushed counts, failure details, planned writes), cancellation flags, timeout deadlines, and indexes for active lookup and reaper/pruner scans.
Job Infrastructure
packages/kit/convex/products/jobs.ts
Complete job management module: timing/retention constants, truncateFailures helper, platform/direction validators, requireProjectMember / requireJobAccess auth helpers, client-facing queries (getActiveSyncJob, getSyncJobById) and mutations (enqueueProductSync, cancelProductSync, dismissCompletedJob), internal worker read/write primitives (getJobForWorker, isCancelRequested, markJobRunning, updateJobProgress, markJobSucceeded, markJobFailed), purge worker action, and scheduled reaper/pruner mutations.
Worker Actions
packages/kit/convex/products/asc.ts, packages/kit/convex/products/play.ts
iOS (ASC) and Android (Google Play) sync workers refactored as internal actions (runProductSyncIOS, runProductSyncAndroid) that receive a jobId, orchestrate pull/push phases with progress reporting and cancellation checks, truncate failures on completion, and delegate status transitions to job mutations. Subscription ingestion now includes billingPeriod and offers derived from intro offers; draft promotion adds dry-run planning via plannedWrites.
Sync Utilities
packages/kit/convex/products/sync.ts
New deletePlatformCatalog internal mutation for bounded, paginated platform product deletion supporting the purge worker.
Scheduled Tasks
packages/kit/convex/crons.ts
Two new cron intervals: 5-minute reaper marking timed-out running jobs as failed, and 6-hour pruner deleting succeeded/failed jobs past retention windows.
HTTP API Endpoints
packages/kit/server/api/v1/products.ts
POST /sync/:platform now enqueues a job and returns 202 with { jobId, deduped }; new GET /sync/jobs/:jobId polls job status; new POST /sync/jobs/:jobId/cancel requests cancellation. Platform param mapped to enum; direction and dryRun parsed.
Project Lookup
packages/kit/convex/projects/internal.ts
Added getProjectById internal query for worker direct project retrieval; getProjectByApiKey refactored to use renamed getProjectByIdFromDb helper.
Frontend Job Display
packages/kit/src/pages/auth/organization/project/products.tsx
Sync flow converted from direct action calls to reactive job queries and mutations. Page queries active per-platform jobs, enqueues/cancels/dismisses via mutations, watches job state to fire success/failure toasts once per job id, and dispatches detailed results (including dry-run plannedWrites). New UI components: DryRunButton, ResetCatalogButton, HorizonCatalogNotice, PurgeConfirmDialog. Button labels computed from job.progress.phase. Results banner gated by dismissed state.
Frontend Supporting Changes
packages/kit/src/components/AuthTransition.tsx, packages/kit/src/components/Tooltip.tsx, packages/kit/src/lib/sentry.ts
AuthTransition enables closeButton on Toaster. New Tooltip component with hover/focus state, side/align positioning, and dark-mode support. Sentry beforeSend hook detects Convex transient failures (load/network errors via regex matching) and tags them for filtering.
Documentation & Tests
packages/kit/CONVENTION.md, packages/docs/src/pages/docs/kit-backend.tsx, packages/kit/convex/products/jobs.test.ts
Convention document details Jobs architecture, schema, enqueue/worker/cron patterns, dashboard and HTTP API behavior. Product sync docs rewritten for async 202 job response, polling endpoints, cancel semantics, and cron reaping. Vitest suite adds truncateFailures unit tests and retention constant sanity checks.

Sequence Diagram

sequenceDiagram
    participant Client
    participant API as HTTP API
    participant Convex as Convex Backend
    participant iOS as iOS Worker
    participant Android as Android Worker
    participant Cron as Scheduled Crons

    Client->>API: POST /sync/ios<br/>{direction, dryRun}
    API->>Convex: enqueueProductSync(platform, direction, dryRun)
    Convex->>Convex: Check for existing queued/running job<br/>(dedup logic)
    Convex->>Convex: Insert job record (queued status)
    Convex->>Convex: Schedule iOS worker action
    Convex-->>API: {jobId, deduped}
    API-->>Client: HTTP 202 {jobId, deduped}

    Client->>API: GET /sync/jobs/{jobId} (poll)
    API->>Convex: getSyncJobById(jobId)
    Convex-->>API: Job {status, progress, result}
    API-->>Client: {status, progress, result}

    par Worker Execution
        Convex->>iOS: runProductSyncIOS(jobId)
        iOS->>Convex: isCancelRequested?
        Convex-->>iOS: false
        iOS->>iOS: PULL phase<br/>(checkCancelled, reportPhase)
        iOS->>Convex: updateJobProgress(phase: "pull")
        iOS->>Convex: PUSH phase<br/>(plan/draft/promote)
        iOS->>Convex: markJobSucceeded(pulled, pushed, failures)
    and
        Convex->>Android: runProductSyncAndroid(jobId)
        Android->>Convex: isCancelRequested?
        Convex-->>Android: false
        Android->>Android: PULL phase
        Android->>Convex: updateJobProgress(phase: "pull")
        Android->>Android: PUSH phase<br/>(plan/patch/create)
        Android->>Convex: markJobSucceeded(pulled, pushed, plannedWrites)
    end

    Client->>API: POST /sync/jobs/{jobId}/cancel
    API->>Convex: cancelProductSync(jobId)
    Convex->>Convex: Set cancelRequested: true
    Worker->>Convex: isCancelRequested?
    Convex-->>Worker: true
    Worker->>Convex: markJobFailed(error: "Cancelled")
    Worker-->>Client: Job status → failed

    Cron->>Convex: reapStaleProductSyncJobs() (every 5 min)
    Convex->>Convex: Find running jobs<br/>past expectedDeadline + grace
    Convex->>Convex: Mark as failed (timeout)

    Cron->>Convex: pruneProductSyncJobs() (every 6 hours)
    Convex->>Convex: Delete old succeeded/failed jobs<br/>past retention windows
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Suggested labels

📖 documentation, ♻️ refactor, ✨ feature, cross-platform

Poem

🐰 A rabbit hops through async dreams,
Jobs enqueued in Convex streams,
Pull and push in coordinated flow,
Progress tracked, with cancellations' glow,
Long-running syncs with grace and care!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature additions: async product sync using a job queue system, dry-run capability, and purge-local functionality.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/kit-async-product-sync

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.

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request migrates the product sync process for App Store Connect and Google Play from synchronous actions to an asynchronous background job system. This change addresses browser-side fetch timeouts, particularly on iOS Safari, by introducing a job-based architecture involving a new productSyncJobs table, internal worker actions, and maintenance crons. The REST API and dashboard UI have been updated to handle job enqueuing, state polling, and cancellation. Additional improvements include a portable base64 encoding fix for V8 isolates, a custom tooltip component, and Sentry filtering for transient network errors. Feedback focused on improving code maintainability by recommending dedicated interfaces for complex function signatures in the sync helpers and the use of named constants for batch sizes in cron tasks.

Comment thread packages/kit/convex/products/asc.ts Outdated
Comment thread packages/kit/convex/products/play.ts Outdated
Comment thread packages/kit/convex/products/jobs.ts

Copilot AI left a comment

Copy link
Copy Markdown

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 migrates kit’s product sync from long-running synchronous actions to an async job-queue model backed by a new productSyncJobs table, enabling progress reporting, cancellation, retention/reaping, and “purge-local” catalog resets. It also updates the dashboard UI to reflect job state reactively, adds a reusable Tooltip, reduces Sentry noise from Convex reconnect fetch failures, and fixes base64 encoding in Convex isolate runtime.

Changes:

  • Introduce productSyncJobs schema + enqueue/cancel/dismiss/job lifecycle logic (workers, progress, retention, reaper/pruner crons).
  • Update dashboard Products page to enqueue jobs (incl. dry-run and purge-local), show progress/result banners, and provide cancel/dismiss UX.
  • Add Tooltip component, Toaster close button support, Sentry beforeSend tagging/fingerprinting for Convex reconnect “Load failed”, and fix readFileAsBase64 for isolate runtime.

Reviewed changes

Copilot reviewed 16 out of 17 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/kit/src/pages/auth/organization/project/products.tsx Switch UI to job-based sync with progress/result banners, dry-run/purge-local/cancel/dismiss actions, and Horizon notice.
packages/kit/src/lib/sentry.ts Tag and fingerprint Convex reconnect “Load failed” noise in beforeSend.
packages/kit/src/components/Tooltip.tsx Add a lightweight tooltip component with opaque surface styling.
packages/kit/src/components/AuthTransition.tsx Enable toast close buttons via Toaster closeButton.
packages/kit/server/api/v1/products.ts Replace sync endpoint with job enqueue + add job poll/cancel routes.
packages/kit/convex/schema.ts Add productSyncJobs table + indexes.
packages/kit/convex/projects/internal.ts Add internal getProjectById for worker job → project lookup.
packages/kit/convex/products/sync.ts Add bounded platform-catalog delete mutation for purge-local.
packages/kit/convex/products/play.ts Replace synchronous Play sync action with job worker + Android dry-run plannedWrites support.
packages/kit/convex/products/jobs.ts New job lifecycle module: enqueue/dedup, cancel, dismiss, progress, reaper/pruner, purge-local worker.
packages/kit/convex/products/jobs.test.ts Unit tests for failure truncation and retention constant sanity checks.
packages/kit/convex/products/asc.ts Replace synchronous ASC sync action with job worker + progress/cancel support.
packages/kit/convex/files/internal.ts Fix base64 encoding in isolate runtime (remove Buffer dependency).
packages/kit/convex/crons.ts Add reaper/pruner intervals for product sync jobs.
packages/kit/convex/_generated/api.d.ts Include generated typings for products/jobs.
packages/kit/CONVENTION.md Document the long-running background job pattern for kit.
packages/docs/src/pages/docs/kit-backend.tsx Update docs to describe async sync job endpoints and polling model.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/kit/server/api/v1/products.ts
Comment thread packages/kit/server/api/v1/products.ts
Comment thread packages/kit/convex/products/jobs.ts Outdated
Comment thread packages/kit/convex/files/internal.ts Outdated
Comment thread packages/docs/src/pages/docs/kit-backend.tsx
@hyochan hyochan added 🛠 bugfix All kinds of bug fixes and removed 🐛 bug Something isn't working labels May 5, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request migrates the product sync process for App Store Connect and Google Play from synchronous actions to an asynchronous background job system to prevent browser-side timeouts. The implementation introduces a productSyncJobs table, dedicated worker actions with cancellation and progress reporting, and maintenance crons for reaping and pruning jobs. The dashboard UI has been updated to support this lifecycle, including progress visualization and dry-run previews. Additionally, the PR fixes a runtime error in base64 encoding within V8 isolates and introduces a custom tooltip component. Feedback was provided regarding the optimization of the getActiveSyncJob query by utilizing a composite index to avoid linear scans.

Comment thread packages/kit/convex/products/jobs.ts
Comment thread packages/kit/convex/schema.ts

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 6

🧹 Nitpick comments (2)
packages/kit/convex/files/internal.ts (1)

195-198: 💤 Low value

Drop the as unknown as number[] double cast in favor of spread.

Uint8Array is iterable, so String.fromCharCode(...bytes.subarray(i, i + CHUNK)) produces the same result without the type-system escape hatch and is the more idiomatic form here. The double cast (as unknown as number[]) silences the compiler rather than expressing intent and can mask future regressions if the underlying expression changes. Behavior is identical: chunked spreads of 32768 bytes stay well below V8's argument-count limit.

♻️ Proposed refactor
     const arrayBuffer = await blob.arrayBuffer();
     const bytes = new Uint8Array(arrayBuffer);
     const CHUNK = 0x8000;
     let binary = "";
     for (let i = 0; i < bytes.length; i += CHUNK) {
-      binary += String.fromCharCode.apply(
-        null,
-        bytes.subarray(i, i + CHUNK) as unknown as number[],
-      );
+      binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
     }
     const base64 = btoa(binary);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/kit/convex/files/internal.ts` around lines 195 - 198, Replace the
double-cast usage in the string-concatenation chunk where you build `binary`
from `bytes` (the line using String.fromCharCode.apply with `bytes.subarray(i, i
+ CHUNK) as unknown as number[]`) by using the iterable spread instead; call
String.fromCharCode with a spread of the subarray (i.e.,
String.fromCharCode(...bytes.subarray(i, i + CHUNK))) so you remove the `as
unknown as number[]` type escape while preserving the chunking behavior with
`CHUNK` and `bytes`.
packages/kit/src/components/Tooltip.tsx (1)

18-30: ⚡ Quick win

Use an interface + JSDoc for the exported Tooltip API.

The component currently uses inline props object definition and lacks a JSDoc comment on the exported function. Extract the props to a TooltipProps interface and add a JSDoc block to match repo TypeScript API conventions (per guidelines: "Prefer interface for defining object shapes in TypeScript" and "Add JSDoc comments for public functions and exported APIs").

♻️ Suggested patch
-type Side = "top" | "bottom" | "left" | "right";
-type Align = "start" | "center" | "end";
+type Side = "top" | "bottom" | "left" | "right";
+type Align = "start" | "center" | "end";
+
+interface TooltipProps {
+  children: ReactNode;
+  content: ReactNode;
+  side?: Side;
+  align?: Align;
+  widthClass?: string;
+}
@@
-export function Tooltip({
+/**
+ * Lightweight hover/focus tooltip wrapper for inline trigger elements.
+ */
+export function Tooltip({
   children,
   content,
   side = "bottom",
   align = "end",
   widthClass = "w-80",
-}: {
-  children: ReactNode;
-  content: ReactNode;
-  side?: Side;
-  align?: Align;
-  widthClass?: string;
-}) {
+}: TooltipProps) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/kit/src/components/Tooltip.tsx` around lines 18 - 30, Extract the
inline props type into an exported interface named TooltipProps (including
children, content, side, align, widthClass with their types and defaults
documented) and update the Tooltip function signature to accept props:
TooltipProps; add a JSDoc block above the exported Tooltip function describing
the component, its props, and default values (mention children, content, side
default "bottom", align default "end", widthClass default "w-80") so the API
follows repository TS conventions; ensure exported interface name and the
Tooltip function are used consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/kit/convex/products/jobs.ts`:
- Around line 386-435: The progress.failuresCount uses different semantics
between markJobSucceeded and markJobFailed: change markJobFailed to set
progress.failuresCount to the rawFailures length (const rawFailures =
args.failures ?? []; use rawFailures.length) so it matches markJobSucceeded's
"true count" behavior; keep using truncateFailures(rawFailures) to populate
result.failures and failuresTruncated but ensure progress.failuresCount
references rawFailures.length, not failures.length.
- Around line 297-314: The comment above the dismissCompletedJob mutation is
stale and mentions shifting completedAt and a dismissed:true field that don't
exist; update the comment to accurately describe the implemented approach: this
handler checks the job status, and when completed sets progress.phase =
"dismissed" (used by the client to mark dismissed jobs) instead of altering
completedAt or adding a dismissed field, and keep the note about reusing
cancelRequested vs. a dismissal marker if desired; locate the comment
immediately above dismissCompletedJob and rewrite it to reflect the actual
behavior and rationale.

In `@packages/kit/convex/products/sync.ts`:
- Around line 359-373: Ensure the mutation validates that args.limit is a
positive integer before using it in handler: check the incoming args.limit (used
where page = ... .take(args.limit + 1), hasMore and toDelete are computed) and
either throw a validation error or coerce to a minimum of 1 so you never call
.take(0) or compute hasMore that deletes zero rows; update the args validation
or add an early guard in the handler to enforce limit > 0 and reject or adjust
invalid values.

In `@packages/kit/server/api/v1/products.ts`:
- Around line 160-163: The type cast for the request query variable direction
currently omits "purge-local"; update the cast expression where direction is
declared so it includes "purge-local" in the union (i.e.
(c.req.query("direction") as "pull" | "push" | "both" | "purge-local" |
undefined) ?? "both") so the local TS type matches the Convex directionValidator
and the documented supported values; keep the default "both" behavior unchanged.

In `@packages/kit/src/lib/sentry.ts`:
- Around line 53-60: The Convex noise classifier currently builds `message` from
`exception` (from `hint.originalException`) and checks `looksConvex` using
`message + " " + (event.request?.url ?? "")`; update it to fallback to
`event.message`/`event.logentry?.message` when `hint.originalException` is not
present so events that only carry an event-level message are detected. In
practice, in the same block that computes `message` and `isFetchLoadFailed`,
augment the source string used for `looksConvex` to include `event.message ||
event.logentry?.message` (in addition to the existing `message` and
`event.request?.url`) before running the
`/convex\.cloud|\/api\/(action|query|mutation)/i` test. Ensure references to
`message`, `looksConvex`, and `event.request?.url` remain and that the fallback
is used only when the original exception-derived message is empty.

In `@packages/kit/src/pages/auth/organization/project/products.tsx`:
- Around line 91-137: The toasts are re-shown after a reload because dismissal
sets progress.phase = "dismissed" but status remains terminal; update the
useEffect that watches iosJob/androidJob to skip showing any toast if
job.progress?.phase === "dismissed" (i.e., treat dismissed as
terminal-no-toast). Concretely, inside the loop (where you currently compute
terminal and check lastShownJobIdRef), add a guard like if (job.progress?.phase
=== "dismissed") continue; so iosJob/androidJob toasts are only shown for
terminal jobs that are not dismissed; keep references to iosJob, androidJob,
lastShownJobIdRef and progress.phase when implementing.

---

Nitpick comments:
In `@packages/kit/convex/files/internal.ts`:
- Around line 195-198: Replace the double-cast usage in the string-concatenation
chunk where you build `binary` from `bytes` (the line using
String.fromCharCode.apply with `bytes.subarray(i, i + CHUNK) as unknown as
number[]`) by using the iterable spread instead; call String.fromCharCode with a
spread of the subarray (i.e., String.fromCharCode(...bytes.subarray(i, i +
CHUNK))) so you remove the `as unknown as number[]` type escape while preserving
the chunking behavior with `CHUNK` and `bytes`.

In `@packages/kit/src/components/Tooltip.tsx`:
- Around line 18-30: Extract the inline props type into an exported interface
named TooltipProps (including children, content, side, align, widthClass with
their types and defaults documented) and update the Tooltip function signature
to accept props: TooltipProps; add a JSDoc block above the exported Tooltip
function describing the component, its props, and default values (mention
children, content, side default "bottom", align default "end", widthClass
default "w-80") so the API follows repository TS conventions; ensure exported
interface name and the Tooltip function are used consistently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b0c20407-6ee4-4795-b9bc-ff9821304d33

📥 Commits

Reviewing files that changed from the base of the PR and between d1599ed and 83d18fc.

⛔ Files ignored due to path filters (1)
  • packages/kit/convex/_generated/api.d.ts is excluded by !**/_generated/**
📒 Files selected for processing (16)
  • packages/docs/src/pages/docs/kit-backend.tsx
  • packages/kit/CONVENTION.md
  • packages/kit/convex/crons.ts
  • packages/kit/convex/files/internal.ts
  • packages/kit/convex/products/asc.ts
  • packages/kit/convex/products/jobs.test.ts
  • packages/kit/convex/products/jobs.ts
  • packages/kit/convex/products/play.ts
  • packages/kit/convex/products/sync.ts
  • packages/kit/convex/projects/internal.ts
  • packages/kit/convex/schema.ts
  • packages/kit/server/api/v1/products.ts
  • packages/kit/src/components/AuthTransition.tsx
  • packages/kit/src/components/Tooltip.tsx
  • packages/kit/src/lib/sentry.ts
  • packages/kit/src/pages/auth/organization/project/products.tsx

Comment thread packages/kit/convex/products/jobs.ts
Comment thread packages/kit/convex/products/jobs.ts
Comment thread packages/kit/convex/products/sync.ts
Comment thread packages/kit/server/api/v1/products.ts
Comment thread packages/kit/src/lib/sentry.ts Outdated
Comment thread packages/kit/src/pages/auth/organization/project/products.tsx
Address all 15 unresolved review threads on PR #127:

- **Auth (Copilot, critical)**: HTTP server's `enqueueProductSync` /
  `getSyncJobById` / `cancelProductSync` calls were going through
  `getAuthUserId`, which fails server-to-server (no logged-in user).
  Replace with apiKey-only auth via `resolveProjectByApiKey` /
  `resolveJobByApiKey` (matching the existing
  `upsertProduct` pattern). `(apiKey, jobId)` is verified together
  so a stolen jobId from another project can't be cancelled / read
  cross-project. Logged-in users still get membership-checked, with
  `createdBy` recorded best-effort.
- **Schema index (Gemini)**: add
  `by_project_platform_created` composite index and switch
  `getActiveSyncJob` to use it — replaces the prior
  `by_project_and_created` + in-memory `.filter(platform)` which
  scanned every job for the project.
- **Purge loop guard (CodeRabbit, critical)**:
  `deletePlatformCatalog` now throws when `limit < 1` to prevent a
  non-progressing purge worker that returns `hasMore: true` while
  deleting zero rows.
- **failuresCount semantics (CodeRabbit)**: `markJobFailed` now
  records `progress.failuresCount` from the raw upstream count (not
  the post-truncation slice length) — matches `markJobSucceeded`
  semantics so analytics readers don't see inconsistent values.
- **O(n²) string concat (Copilot)**: `readFileAsBase64` now
  accumulates chunks in an array and `join("")`s once, instead of
  growing a `binary` string per iteration. Avoids quadratic memory
  thrash on multi-MB uploads.
- **Sentry classifier (CodeRabbit)**: `beforeSend` now also reads
  `event.message` and `event.logentry?.message`, so reconnect
  events that arrive without `originalException` still get tagged.
- **Toast on dismiss (CodeRabbit)**: products.tsx toast effect
  now skips terminal jobs with `progress.phase === "dismissed"` so
  the success/failure toast doesn't re-fire after every page
  reload.
- **Direction cast (CodeRabbit)**: HTTP route's TS cast on
  `direction` query param now includes `"purge-local"`.
- **Reaper batch (Gemini)**: `PRODUCT_SYNC_REAPER_BATCH = 50` and
  `PRODUCT_SYNC_PRUNER_BATCH = 100` named constants replace the
  hardcoded `.take(50)` / `.take(100)` calls.
- **Sync options (Gemini)**: extracted `IosSyncOptions` /
  `AndroidSyncOptions` interfaces to clean up the long
  `performIosSync` / `performAndroidSync` signatures.
- **Stale comment (Copilot, CodeRabbit)**: rewritten
  `dismissCompletedJob` doc to match the actual
  `progress.phase = "dismissed"` implementation (no `completedAt`
  shifting, no `dismissed: true` field).

Tests: jobs.test.ts unchanged, 346/346 vitest still passing.
Typecheck / lint / format / smoke:server all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request migrates the product synchronization process to an asynchronous background job system to prevent browser timeouts, introducing a productSyncJobs table, background workers, and a polling mechanism. Key updates include refactoring sync logic into internal actions, implementing cron jobs for task reaping and pruning, and enhancing the dashboard UI with progress tracking and cancellation support. It also fixes a base64 encoding bug in V8 isolates and improves Sentry noise filtering. Feedback highlights a missing currency validation in the Android sync path and low-contrast text colors in the UI result banner that need theme-aware adjustments.

Comment thread packages/kit/convex/products/play.ts
Comment thread packages/kit/src/pages/auth/organization/project/products.tsx

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 16 out of 17 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/kit/convex/products/jobs.ts
Comment thread packages/kit/src/pages/auth/organization/project/products.tsx Outdated
Comment thread packages/kit/src/pages/auth/organization/project/products.tsx Outdated
Comment thread packages/kit/src/components/Tooltip.tsx
Address the second pass of Copilot/Gemini findings on PR #127:

- **Atomic enqueue dedup (Copilot critical)**: the prior index-based
  dedup wasn't transactional — two concurrent `enqueueProductSync`
  mutations could both observe an empty `productSyncJobs` index
  range, both insert separate rows, and fan out conflicting
  workers. Replaced with an `activeSyncJobIds: { IOS?, Android? }`
  field on `projects`. Reading + patching that field forces Convex's
  OCC to collapse the two callers onto one job — only one commit
  wins, the loser retries, sees the lock, and returns deduped.
  Worker terminations (`markJobSucceeded` / `markJobFailed` /
  reaper) clear the matching lock entry so the next enqueue can
  claim the slot.
- **Android USD guard (Gemini)**: Play's `regionalConfigs` requires
  `currencyCode` to match `regionCode`. Pushing `regionCode: "US"`
  with a non-USD price returns a generic 400 from the API. Match
  the iOS path's currency-validation pattern and surface an
  actionable failure when the row's currency != USD before the
  request fires (dry-run + real run both validate).
- **Modal promoted to shared component (Copilot)**:
  `AuthModal/Modal.tsx` moved to `src/components/Modal.tsx` with
  `showCloseButton` and `contentClassName` props so the
  destructive `PurgeConfirmDialog` reuses the focus trap, escape
  handler, scroll lock, and focus-restore behavior instead of
  re-implementing them inline (the prior dialog let keyboard users
  tab into background controls while a destructive confirm was
  open). AuthModal updated to import from the new location.
- **Tooltip a11y (Copilot)**: trigger now gets `aria-describedby`
  pointing at a stable `useId`-generated tooltip id so screen
  readers announce the help text alongside the button label.
  `onFocus` paired with `onBlurCapture` + a `relatedTarget`
  containment check keeps the tooltip open while focus moves
  between descendants of the wrapper instead of closing on every
  bubbled blur.
- **Cancel toast (Copilot)**: `onCancel` now branches on the
  mutation's returned `{ ok, reason }`. Job already completed
  between render and click → "sync already finished"; otherwise →
  "cancellation requested". The misleading "cancellation
  requested" toast on no-op cancels is gone.
- **Light-mode contrast (Gemini)**: result banner + reset button +
  amber warning + delete button colors now have `text-{color}-700
  dark:text-{color}-200` pairs. The earlier dark-only palette
  (`text-rose-200` etc.) was unreadable in light mode against the
  bg-tint surface.

346/346 vitest, typecheck/lint/format clean, smoke:server green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hyodotdev hyodotdev deleted a comment from coderabbitai Bot May 5, 2026
@hyodotdev hyodotdev deleted a comment from coderabbitai Bot May 5, 2026
@hyochan hyochan merged commit faae26a into main May 5, 2026
9 checks passed
@hyochan hyochan deleted the feat/kit-async-product-sync branch May 5, 2026 10:39

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request transitions the product sync process from a synchronous action to an asynchronous background job system to prevent browser timeouts, specifically addressing iOS Safari's TypeError: Load failed. It introduces a new productSyncJobs table, worker actions for iOS and Android, and a polling mechanism for the dashboard and API. Key changes include atomic job deduping using a project-level lock, a "purge-local" recovery feature, and a shared Modal component. Feedback focuses on improving the robustness of base64 encoding for large files to avoid stack size limits and ensuring idiomatic clearing of nested lock fields in Convex patch calls to avoid leaving empty objects in the database.

Comment on lines +451 to +454
activeSyncJobIds: {
...project.activeSyncJobIds,
[job.platform]: undefined,
},

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.

medium

In Convex, setting a field to undefined within a patch call causes that field to be ignored during the merge. While this works for top-level fields to omit them from the update, when used inside a nested object like activeSyncJobIds, it results in the key being omitted from the new object value. This effectively clears the lock, but it leaves an empty object {} in the database if no other platform locks are present. A more explicit approach using delete or setting the specific platform key to undefined on a copy of the object is preferred for clarity.

        const nextActiveSyncJobIds = { ...project.activeSyncJobIds };
        delete nextActiveSyncJobIds[job.platform];
        await ctx.db.patch(project._id, {
          activeSyncJobIds: nextActiveSyncJobIds,
        });

Comment on lines +502 to +505
...project.activeSyncJobIds,
[job.platform]: undefined,
},
});

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.

medium

Similar to the success path, clearing the lock by spreading and setting a key to undefined relies on Convex's behavior of ignoring undefined values in objects. Using delete on a copy of the object is more idiomatic for removing keys from a nested map-like structure.

        const nextActiveSyncJobIds = { ...project.activeSyncJobIds };
        delete nextActiveSyncJobIds[job.platform];
        await ctx.db.patch(project._id, {
          activeSyncJobIds: nextActiveSyncJobIds,
        });

Comment on lines +537 to +540
...project.activeSyncJobIds,
[job.platform]: undefined,
},
});

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.

medium

The reaper should also use an explicit key deletion to clear the lock, ensuring consistency with the success and failure terminal states.

        const nextActiveSyncJobIds = { ...project.activeSyncJobIds };
        delete nextActiveSyncJobIds[job.platform];
        await ctx.db.patch(project._id, {
          activeSyncJobIds: nextActiveSyncJobIds,
        });

Comment on lines +199 to +204
String.fromCharCode.apply(
null,
bytes.subarray(i, i + CHUNK) as unknown as number[],
),
);
}

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.

medium

While String.fromCharCode.apply is a common pattern for converting byte arrays to strings, it is subject to stack size limits in many JavaScript engines (including V8). Although the CHUNK size of 0x8000 (32,768) is generally safe, very large files could still potentially hit limits if the environment's stack is constrained. Using TextDecoder (if available in the runtime) or a manual loop to build the string from the Uint8Array is more robust for arbitrary file sizes.

    for (let i = 0; i < bytes.length; i += CHUNK) {
      const subarray = bytes.subarray(i, i + CHUNK);
      let chunk = "";
      for (let j = 0; j < subarray.length; j++) {
        chunk += String.fromCharCode(subarray[j]);
      }
      chunks.push(chunk);
    }

hyochan added a commit that referenced this pull request May 5, 2026
…ASC private key

`resolveAscCredentials` required both `iosAscIssuerId` AND
`iosAscKeyId` to be populated before using the ASC pair. The
Settings UI deliberately exposes only ONE Issuer ID input
(documented as "shared with Server API above") because Apple uses
a single Issuer per team across both API gateways — so production
projects always have `iosAscIssuerId = undefined` and
`iosAscKeyId = "<asc team key id>"`.

Old gate (`iosAscIssuerId && iosAscKeyId`) returned false →
fallback path used `iosAppStoreKeyId` (the In-App Purchase /
Server API key id) but signed the JWT with the ASC private key
content (loaded via `getAppleAscApiKey`). Apple rejected every
request with 401 because `kid` claim didn't match the signing
key. Affects every production tenant; reported by LukasB-DEV in
the PR #127 thread, reproduced by hyochan on kit.openiap.dev
("localhost works, prod doesn't" — dev DBs happened to have
matching values from earlier UI iterations).

Fix: gate on `iosAscKeyId` alone, fall back issuer to
`iosAppStoreIssuerId` when the dedicated ASC issuer slot is
empty. Behavior:
- ASC slot has key id → ASC pair (`issuerId`: ASC slot ?? legacy
  shared, `keyId`: ASC slot, `.p8`: ASC slot ?? legacy)
- ASC slot empty → legacy Server API pair end-to-end (back-compat
  for projects that uploaded the ASC key into the legacy slot
  before the second slot existed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants