From 83d18fc7fe69ab709b21c06561108ea878d97c02 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Tue, 5 May 2026 18:34:59 +0900
Subject: [PATCH 1/3] feat(kit): async product sync with job queue, dry-run,
and purge-local
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)
---
packages/docs/src/pages/docs/kit-backend.tsx | 45 +-
packages/kit/CONVENTION.md | 53 +
packages/kit/convex/_generated/api.d.ts | 2 +
packages/kit/convex/crons.ts | 19 +
packages/kit/convex/files/internal.ts | 21 +-
packages/kit/convex/products/asc.ts | 1274 +++++++++--------
packages/kit/convex/products/jobs.test.ts | 61 +
packages/kit/convex/products/jobs.ts | 487 +++++++
packages/kit/convex/products/play.ts | 968 +++++++------
packages/kit/convex/products/sync.ts | 31 +
packages/kit/convex/projects/internal.ts | 15 +-
packages/kit/convex/schema.ts | 80 ++
packages/kit/server/api/v1/products.ts | 88 +-
.../kit/src/components/AuthTransition.tsx | 4 +-
packages/kit/src/components/Tooltip.tsx | 96 ++
packages/kit/src/lib/sentry.ts | 24 +
.../auth/organization/project/products.tsx | 592 ++++++--
17 files changed, 2742 insertions(+), 1118 deletions(-)
create mode 100644 packages/kit/convex/products/jobs.test.ts
create mode 100644 packages/kit/convex/products/jobs.ts
create mode 100644 packages/kit/src/components/Tooltip.tsx
diff --git a/packages/docs/src/pages/docs/kit-backend.tsx b/packages/docs/src/pages/docs/kit-backend.tsx
index bafa0ca8..a999b5ea 100644
--- a/packages/docs/src/pages/docs/kit-backend.tsx
+++ b/packages/docs/src/pages/docs/kit-backend.tsx
@@ -183,14 +183,49 @@ if status.active:
- The{' '}
+ Sync is asynchronous —{' '}
POST /v1/products/{apiKey}/sync/{ios|android}
{' '}
- endpoint returns the count of pulled / pushed rows plus per-product
- failure messages so the dashboard surfaces upstream rejections
- (price-tier conflicts, locale issues, missing review notes) without
- dropping silent failures.
+ enqueues a job and returns { jobId, deduped }{' '}
+ with HTTP 202 immediately. The actual catalog walk (App Store Connect
+ REST or Play Developer API) runs in the background as a Convex
+ internalAction, writing progress and the final result back to a{' '}
+ productSyncJobs row. Earlier kit versions held the HTTP
+ connection open for the entire sync, which iOS Safari aborted on
+ cellular / backgrounded tabs as TypeError: Load failed.
+
+
Clients poll the job state:
+
+
+
+ GET /v1/products/{apiKey}/sync/jobs/{jobId}
+ {' '}
+ — current status (queued / running /{' '}
+ succeeded / failed),{' '}
+ progress.phase, and on terminal status the{' '}
+ result object with pulled /{' '}
+ pushed counts and per-product failures{' '}
+ (price-tier conflicts, locale issues, missing review notes). When
+ more than 200 products fail, failuresTruncated: true is
+ set and the array is capped.
+
+
+
+ POST
+ /v1/products/{apiKey}/sync/jobs/{jobId}/cancel
+ {' '}
+ — request a cancel; the worker checks at phase boundaries (PULL.iaps
+ → PULL.subscriptions → PUSH.drafts) and stops within seconds.
+
+
+
+ Backoff polls at ~3s intervals until status is{' '}
+ succeeded or failed. Most catalogs finish in
+ tens of seconds; large ones in 1–2 minutes. A{' '}
+ reapStaleProductSyncJobs cron flips workers stuck past
+ the 9-minute deadline to failed so a crashed action can't
+ pin the project's "active job" slot.
diff --git a/packages/kit/CONVENTION.md b/packages/kit/CONVENTION.md
index 2f642271..784de0ad 100644
--- a/packages/kit/CONVENTION.md
+++ b/packages/kit/CONVENTION.md
@@ -151,6 +151,59 @@ boots it on port 3100, and probes `/health`, `/`, `/api/v1` — catches
startup regressions (missing env, bind conflicts, missing
`dist/index.html`).
+## Long-running operations
+
+Convex actions cap at ~10 minutes; the browser fetch holding an
+action result open is bounded much more aggressively (iOS Safari
+aborts pending fetches when a tab backgrounds or the network
+flips, surfacing as `TypeError: Load failed`). Anything that walks
+an external catalog or fans out per-product API calls — App Store
+Connect / Play Console sync, Meta Horizon reconciliation, future
+Stripe price sync — must run as a background job, not as a
+synchronous public action the dashboard awaits.
+
+Pattern (mirrors `convex/products/jobs.ts` + `runProductSyncIOS` /
+`runProductSyncAndroid`):
+
+1. **Schema**: a `*Jobs` table with `status`
+ (`queued | running | succeeded | failed`), `progress` (`{phase,
+ current?, total?, failuresCount?}`), `result?`, `error?`,
+ `cancelRequested?`, `expectedDeadline?`, `createdBy?`,
+ `startedAt?`, `completedAt?`, `createdAt`. Indexes:
+ `(projectId, platform, status)` for active-job lookup,
+ `(status, expectedDeadline)` for the reaper,
+ `(status, completedAt)` for the pruner.
+2. **Enqueue mutation**: validates membership, dedups against an
+ existing `queued`/`running` row for the same `(projectId,
+ platform)`, inserts the row, schedules the worker via
+ `ctx.scheduler.runAfter(0, internal..runX, { jobId })`.
+ Returns `{ jobId, deduped }`.
+3. **Worker internalAction**: `args: { jobId }`. Reads job →
+ resolves project → runs the work, calling
+ `updateJobProgress` at phase boundaries and
+ `isCancelRequested` between phases. Wraps the body in
+ try/catch and finishes via `markJobSucceeded` /
+ `markJobFailed` so a thrown error never leaves the row in
+ `running` forever.
+4. **Cron pair**: `reapStaleProductSyncJobs` (5 min, flips
+ `running` rows past `expectedDeadline + grace` to `failed`)
+ and `pruneProductSyncJobs` (6 h, deletes `succeeded` rows
+ older than 7 d / `failed` rows older than 30 d).
+5. **Dashboard**: `useQuery(getActiveSyncJob)` for the reactive
+ button state + progress label; `useMutation(enqueue*)` to
+ start; `useMutation(cancel*)` to stop. The completion toast
+ fires once via a `useRef`-gated `useEffect` so reactive
+ updates don't re-toast.
+6. **HTTP**: `POST .../sync/...` returns 202 with `{ jobId,
+ deduped }`; `GET .../sync/jobs/{jobId}` polls; `POST
+ .../sync/jobs/{jobId}/cancel` cancels. Clients backoff at ~3 s
+ intervals.
+
+Failures arrays should pass through `truncateFailures` (cap 200,
+sets `failuresTruncated: true`) so a runaway sync where every
+product fails for the same reason can't blow past Convex's
+per-document size budget.
+
## Commit messages
Follow the monorepo-wide convention from the root
diff --git a/packages/kit/convex/_generated/api.d.ts b/packages/kit/convex/_generated/api.d.ts
index 30f95d3d..d879f8c1 100644
--- a/packages/kit/convex/_generated/api.d.ts
+++ b/packages/kit/convex/_generated/api.d.ts
@@ -29,6 +29,7 @@ import type * as organizations_mutation from "../organizations/mutation.js";
import type * as organizations_query from "../organizations/query.js";
import type * as plans from "../plans.js";
import type * as products_asc from "../products/asc.js";
+import type * as products_jobs from "../products/jobs.js";
import type * as products_jwt from "../products/jwt.js";
import type * as products_mutation from "../products/mutation.js";
import type * as products_play from "../products/play.js";
@@ -106,6 +107,7 @@ declare const fullApi: ApiFromModules<{
"organizations/query": typeof organizations_query;
plans: typeof plans;
"products/asc": typeof products_asc;
+ "products/jobs": typeof products_jobs;
"products/jwt": typeof products_jwt;
"products/mutation": typeof products_mutation;
"products/play": typeof products_play;
diff --git a/packages/kit/convex/crons.ts b/packages/kit/convex/crons.ts
index 679a61b6..a926cf39 100644
--- a/packages/kit/convex/crons.ts
+++ b/packages/kit/convex/crons.ts
@@ -81,4 +81,23 @@ crons.interval(
{ batchSize: 50 },
);
+// Mark stuck product-sync jobs as failed. Convex caps actions at
+// ~10min; the worker sets `expectedDeadline = startedAt + 9min`,
+// and this reaper flips anything still `running` past
+// `deadline + 1min` to failed("worker timed out"). Without it, a
+// crashed action permanently pins the project's "active job" slot
+// and the dashboard's button stays disabled forever.
+crons.interval(
+ "reap stale product sync jobs",
+ { minutes: 5 },
+ internal.products.jobs.reapStaleProductSyncJobs,
+);
+
+// Drop succeeded jobs after 7d, failed after 30d.
+crons.interval(
+ "prune product sync jobs past retention",
+ { hours: 6 },
+ internal.products.jobs.pruneProductSyncJobs,
+);
+
export default crons;
diff --git a/packages/kit/convex/files/internal.ts b/packages/kit/convex/files/internal.ts
index f3ca9d82..40ba9dac 100644
--- a/packages/kit/convex/files/internal.ts
+++ b/packages/kit/convex/files/internal.ts
@@ -179,10 +179,25 @@ export const readFileAsBase64 = internalAction({
// accessCount: (file.accessCount || 0) + 1,
// });
- // Convert blob to base64 via Buffer (Convex actions run on Node/Bun
- // — Buffer is available globally and avoids the per-byte loop).
+ // Convex `internalAction`s without `"use node"` run in the V8
+ // isolate runtime where `Buffer` is NOT a global — using it
+ // throws `ReferenceError: Buffer is not defined` at request time
+ // (the prior `Buffer.from(...)` shipped here was the bug behind
+ // the dashboard's "download .p8" failing). Encode via `btoa` on
+ // chunked binary strings so the path stays portable to either
+ // runtime; chunking keeps the call-stack bound below the
+ // String.fromCharCode argument limit for files of any size.
const arrayBuffer = await blob.arrayBuffer();
- const base64 = Buffer.from(arrayBuffer).toString("base64");
+ 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[],
+ );
+ }
+ const base64 = btoa(binary);
return {
fileId: file._id,
diff --git a/packages/kit/convex/products/asc.ts b/packages/kit/convex/products/asc.ts
index c25eda37..06cdc532 100644
--- a/packages/kit/convex/products/asc.ts
+++ b/packages/kit/convex/products/asc.ts
@@ -1,13 +1,24 @@
"use node";
import { v } from "convex/values";
-import { action, type ActionCtx } from "../_generated/server";
+import { action, internalAction, type ActionCtx } from "../_generated/server";
import { internal } from "../_generated/api";
import { getProjectByApiKey } from "../purchases/shared";
import { mapWithConcurrency } from "../utils/concurrency";
import { mintAscJwt } from "./jwt";
import { coerceBillingPeriod } from "./sync";
+// Cancel-check at phase boundaries. The worker reads
+// `cancelRequested` between PULL.iaps → PULL.subgroups → PUSH.drafts.
+// Granularity is per-phase, not per-product, but that's enough to
+// stop a runaway sync within seconds on most paths.
+class ProductSyncCancelledError extends Error {
+ constructor() {
+ super("Sync cancelled by operator");
+ this.name = "ProductSyncCancelledError";
+ }
+}
+
// Resolve App Store Connect API credentials (issuer ID + key ID + .p8
// key content) for a project. Centralized so the two action handlers
// (pushSyncProductsAppleIOS and listSubscriptionGroupsAppleIOS) share
@@ -914,125 +925,268 @@ function extractAscError(parsed: unknown): string {
// = "Draft" / "Ready" upstream.
// ---------------------------------------------------------------------------
-// NOTE on action duration: this handler runs sequentially across the
-// project's catalog (with mapWithConcurrency=6 fan-out per pull
-// step). Convex actions have a 10-minute hard ceiling. For typical
-// commercial apps with <100 SKUs the round trips finish in ~10-30s
-// even with throttled ASC. Catalogs north of ~500 SKUs may need
-// batching — splitting drafts into chunks and chaining
-// internal.scheduler runs from the kit dashboard. Tracked as a
-// follow-up; not addressed here because v0 SaaS targets the
-// long-tail-of-commercial-apps profile, not multi-thousand-SKU
-// enterprise catalogs.
-export const pushSyncProductsAppleIOS = action({
- args: {
- apiKey: v.string(),
- direction: v.optional(
- v.union(v.literal("pull"), v.literal("push"), v.literal("both")),
- ),
- // Dry-run mode: read-only against ASC. Skips every POST/PATCH
- // (create subscription / IAP, localization, price schedule, group
- // create) and instead returns the sequence of write attempts
- // kit would have made. Lets the operator preview a Sync without
- // polluting their App Store Connect catalog with test rows that
- // Apple won't let them delete cleanly.
- dryRun: v.optional(v.boolean()),
- },
- returns: v.object({
- pulled: v.number(),
- pushed: v.number(),
- failures: v.array(v.object({ productId: v.string(), reason: v.string() })),
- plannedWrites: v.optional(
- v.array(
- v.object({
- productId: v.string(),
- step: v.string(),
- detail: v.optional(v.string()),
- }),
- ),
- ),
- }),
- handler: async (
- ctx,
- args,
- ): Promise<{
- pulled: number;
- pushed: number;
- failures: Array<{ productId: string; reason: string }>;
- plannedWrites?: Array<{
- productId: string;
- step: string;
- detail?: string;
- }>;
- }> => {
- const project = await getProjectByApiKey(ctx, args.apiKey);
- if (!project.iosBundleId) {
- throw new Error("Project iosBundleId is not configured");
+// Worker that drives a single ASC sync job. Scheduled by
+// `enqueueProductSync` (in `products/jobs.ts`); never called
+// directly by the dashboard / HTTP / SDK paths so the long fetch
+// can never hold a browser connection open.
+//
+// Convex actions cap at ~10 minutes; we set the job's expected
+// deadline at 9 minutes and rely on `reapStaleProductSyncJobs` to
+// flip anything still running 1 minute past that to failed. Within
+// the action body we also poll `isCancelRequested` at phase
+// boundaries (PULL.iaps → PULL.subgroups → PUSH.drafts) so an
+// operator-initiated cancel takes effect within one phase.
+export const runProductSyncIOS = internalAction({
+ args: { jobId: v.id("productSyncJobs") },
+ handler: async (ctx, args): Promise => {
+ const job = await ctx.runQuery(internal.products.jobs.getJobForWorker, {
+ jobId: args.jobId,
+ });
+ if (!job) return;
+ if (job.status !== "queued") return;
+ await ctx.runMutation(internal.products.jobs.markJobRunning, {
+ jobId: args.jobId,
+ });
+ const checkCancelled = async () => {
+ const cancelled = await ctx.runQuery(
+ internal.products.jobs.isCancelRequested,
+ { jobId: args.jobId },
+ );
+ if (cancelled) throw new ProductSyncCancelledError();
+ };
+ const reportPhase = async (
+ phase: string,
+ extra?: {
+ current?: number;
+ total?: number;
+ failuresCount?: number;
+ },
+ ) => {
+ await ctx.runMutation(internal.products.jobs.updateJobProgress, {
+ jobId: args.jobId,
+ phase,
+ current: extra?.current,
+ total: extra?.total,
+ failuresCount: extra?.failuresCount,
+ });
+ };
+ if (job.direction === "purge-local") {
+ // enqueue routes purge-local jobs to a different worker; this
+ // branch is unreachable in practice but narrows the type for
+ // the call below.
+ await ctx.runMutation(internal.products.jobs.markJobFailed, {
+ jobId: args.jobId,
+ error: "purge-local routed to wrong worker",
+ });
+ return;
}
- if (!project.iosAppAppleId) {
- throw new Error("Project iosAppAppleId is required for ASC push-sync");
+ try {
+ const result = await performIosSync(ctx, {
+ projectId: job.projectId,
+ direction: job.direction,
+ dryRun: job.dryRun,
+ checkCancelled,
+ reportPhase,
+ });
+ await ctx.runMutation(internal.products.jobs.markJobSucceeded, {
+ jobId: args.jobId,
+ pulled: result.pulled,
+ pushed: result.pushed,
+ failures: result.failures,
+ plannedWrites: result.plannedWrites,
+ });
+ } catch (error) {
+ const cancelled = error instanceof ProductSyncCancelledError;
+ const message = cancelled
+ ? "Cancelled by operator"
+ : error instanceof Error
+ ? error.message
+ : String(error);
+ await ctx.runMutation(internal.products.jobs.markJobFailed, {
+ jobId: args.jobId,
+ error: message,
+ });
}
- // ASC push-sync uses the App Store Connect API key (Team Key /
- // Individual Key), which is genuinely different from the App Store
- // Server API key used for receipt verification — Apple scopes them
- // separately at the gateway. We prefer the dedicated ASC slot when
- // the operator has populated it, but fall back to the existing
- // Server API slot so projects that upload a Team Key into the old
- // (single-slot) workflow keep working without a re-config dance.
- // The 401 from Apple's gateway is what catches a wrong-kind key
- // either way — the helpful message in `call()` points the operator
- // at the right Apple page. The full pair-resolve + .p8-fallback
- // logic lives in `resolveAscCredentials` so the matching
- // listSubscriptionGroupsAppleIOS handler stays in lockstep.
- const { issuerId, keyId, keyContent } = await resolveAscCredentials(
- ctx,
- project,
- { detailedErrors: true },
- );
- const client = new AscClient(issuerId, keyId, keyContent);
+ },
+});
+
+async function performIosSync(
+ ctx: ActionCtx,
+ options: {
+ projectId: import("../_generated/dataModel").Id<"projects">;
+ direction: "pull" | "push" | "both";
+ dryRun: boolean;
+ checkCancelled: () => Promise;
+ reportPhase: (
+ phase: string,
+ extra?: {
+ current?: number;
+ total?: number;
+ failuresCount?: number;
+ },
+ ) => Promise;
+ },
+): Promise<{
+ pulled: number;
+ pushed: number;
+ failures: Array<{ productId: string; reason: string }>;
+ plannedWrites?: Array<{ productId: string; step: string; detail?: string }>;
+}> {
+ const project = await ctx.runQuery(
+ internal.projects.internal.getProjectById,
+ { projectId: options.projectId },
+ );
+ if (!project) {
+ throw new Error("Project not found for sync job");
+ }
+ if (!project.iosBundleId) {
+ throw new Error("Project iosBundleId is not configured");
+ }
+ if (!project.iosAppAppleId) {
+ throw new Error("Project iosAppAppleId is required for ASC push-sync");
+ }
+ const args = {
+ direction: options.direction,
+ dryRun: options.dryRun,
+ };
+ const { checkCancelled, reportPhase } = options;
+ // ASC push-sync uses the App Store Connect API key (Team Key /
+ // Individual Key), which is genuinely different from the App Store
+ // Server API key used for receipt verification — Apple scopes them
+ // separately at the gateway. We prefer the dedicated ASC slot when
+ // the operator has populated it, but fall back to the existing
+ // Server API slot so projects that upload a Team Key into the old
+ // (single-slot) workflow keep working without a re-config dance.
+ // The 401 from Apple's gateway is what catches a wrong-kind key
+ // either way — the helpful message in `call()` points the operator
+ // at the right Apple page. The full pair-resolve + .p8-fallback
+ // logic lives in `resolveAscCredentials` so the matching
+ // listSubscriptionGroupsAppleIOS handler stays in lockstep.
+ const { issuerId, keyId, keyContent } = await resolveAscCredentials(
+ ctx,
+ project,
+ { detailedErrors: true },
+ );
+ const client = new AscClient(issuerId, keyId, keyContent);
+
+ const direction = args.direction ?? "both";
+ const failures: Array<{ productId: string; reason: string }> = [];
+ let pulled = 0;
+ let pushed = 0;
+ const dryRun = args.dryRun ?? false;
+ const plannedWrites: Array<{
+ productId: string;
+ step: string;
+ detail?: string;
+ }> = [];
- const direction = args.direction ?? "both";
- const failures: Array<{ productId: string; reason: string }> = [];
- let pulled = 0;
- let pushed = 0;
- const dryRun = args.dryRun ?? false;
- const plannedWrites: Array<{
- productId: string;
- step: string;
- detail?: string;
- }> = [];
+ const appIdStr = String(project.iosAppAppleId);
- const appIdStr = String(project.iosAppAppleId);
+ // ── PULL: ASC → kit catalog ────────────────────────────────────
+ if (direction === "pull" || direction === "both") {
+ await checkCancelled();
+ await reportPhase("pull-iaps");
+ const iaps = await client.listInAppPurchases(appIdStr).catch((error) => {
+ failures.push({
+ productId: "(asc list iaps)",
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ return null;
+ });
+ if (iaps) {
+ // Apple throttles ASC pretty aggressively (~50 req/min);
+ // concurrency=6 keeps the pull fast for catalogs with dozens
+ // of IAPs while staying well clear of 429 territory. Switching
+ // from a sequential await loop dropped a 30-IAP pull from
+ // ~30s to ~5s in local testing.
+ const iapResults = await mapWithConcurrency(
+ iaps.data,
+ 6,
+ async (item) => {
+ const productId = item.attributes.productId;
+ if (!productId) return null;
+ const type = mapAscIapType(item.attributes.inAppPurchaseType);
+ const pricePoint = await client.iapCurrentPrice(item.id);
+ return { item, productId, type, pricePoint };
+ },
+ );
+ for (const result of iapResults) {
+ if (!result) continue;
+ const { item, productId, type, pricePoint } = result;
+ if (pricePoint instanceof Error) {
+ failures.push({
+ productId: `${productId} (price lookup)`,
+ reason: pricePoint.message,
+ });
+ }
+ const { priceAmountMicros, currency } = parseAssignedPrice(
+ pricePoint instanceof Error ? null : pricePoint,
+ "inAppPurchasePricePoint",
+ );
+ // upsertFromStore runs serially — Convex coalesces writes
+ // anyway and parallel mutations on the same row would race
+ // on the (projectId, platform, productId) lookup.
+ await ctx.runMutation(internal.products.sync.upsertFromStore, {
+ projectId: project._id,
+ productId,
+ platform: "IOS",
+ type,
+ title: item.attributes.name ?? productId,
+ priceAmountMicros,
+ currency,
+ storeRef: item.id,
+ state: mapAscState(item.attributes.state),
+ });
+ pulled += 1;
+ }
+ }
- // ── PULL: ASC → kit catalog ────────────────────────────────────
- if (direction === "pull" || direction === "both") {
- const iaps = await client.listInAppPurchases(appIdStr).catch((error) => {
+ await checkCancelled();
+ await reportPhase("pull-subscriptions", {
+ current: pulled,
+ failuresCount: failures.length,
+ });
+ const groups = await client
+ .listSubscriptionGroups(appIdStr)
+ .catch((error) => {
failures.push({
- productId: "(asc list iaps)",
+ productId: "(asc list groups)",
reason: error instanceof Error ? error.message : String(error),
});
return null;
});
- if (iaps) {
- // Apple throttles ASC pretty aggressively (~50 req/min);
- // concurrency=6 keeps the pull fast for catalogs with dozens
- // of IAPs while staying well clear of 429 territory. Switching
- // from a sequential await loop dropped a 30-IAP pull from
- // ~30s to ~5s in local testing.
- const iapResults = await mapWithConcurrency(
- iaps.data,
+ if (groups) {
+ for (const group of groups.data) {
+ const subs = await client
+ .listSubscriptionsInGroup(group.id)
+ .catch((error) => {
+ failures.push({
+ productId: `(asc list subs in group ${group.id})`,
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ return null;
+ });
+ if (!subs) continue;
+ // Same parallelization as the IAP loop above. Within each
+ // sub, price lookup and intro-offer lookup are independent
+ // — fire them as a Promise.all to halve the per-item RTT
+ // before walking on to the upsert.
+ const subResults = await mapWithConcurrency(
+ subs.data,
6,
- async (item) => {
- const productId = item.attributes.productId;
+ async (sub) => {
+ const productId = sub.attributes.productId;
if (!productId) return null;
- const type = mapAscIapType(item.attributes.inAppPurchaseType);
- const pricePoint = await client.iapCurrentPrice(item.id);
- return { item, productId, type, pricePoint };
+ const [pricePoint, introOffers] = await Promise.all([
+ client.subCurrentPrice(sub.id),
+ client.subIntroductoryOffer(sub.id),
+ ]);
+ return { sub, productId, pricePoint, introOffers };
},
);
- for (const result of iapResults) {
+ for (const result of subResults) {
if (!result) continue;
- const { item, productId, type, pricePoint } = result;
+ const { sub, productId, pricePoint, introOffers } = result;
if (pricePoint instanceof Error) {
failures.push({
productId: `${productId} (price lookup)`,
@@ -1041,560 +1195,494 @@ export const pushSyncProductsAppleIOS = action({
}
const { priceAmountMicros, currency } = parseAssignedPrice(
pricePoint instanceof Error ? null : pricePoint,
- "inAppPurchasePricePoint",
+ "subscriptionPricePoint",
+ );
+ if (introOffers instanceof Error) {
+ failures.push({
+ productId: `${productId} (offers lookup)`,
+ reason: introOffers.message,
+ });
+ }
+ const offers = parseIntroOffers(
+ introOffers instanceof Error ? null : introOffers,
);
- // upsertFromStore runs serially — Convex coalesces writes
- // anyway and parallel mutations on the same row would race
- // on the (projectId, platform, productId) lookup.
await ctx.runMutation(internal.products.sync.upsertFromStore, {
projectId: project._id,
productId,
platform: "IOS",
- type,
- title: item.attributes.name ?? productId,
+ type: "Subscription",
+ title: sub.attributes.name ?? productId,
priceAmountMicros,
currency,
- storeRef: item.id,
- state: mapAscState(item.attributes.state),
+ storeRef: sub.id,
+ state: mapAscState(sub.attributes.state),
+ billingPeriod: coerceBillingPeriod(
+ mapAscOfferDurationToIso(
+ sub.attributes.subscriptionPeriod ?? undefined,
+ ),
+ ),
+ subscriptionGroupId: group.id,
+ subscriptionGroupName: group.attributes.referenceName,
+ offers: offers.length ? offers : undefined,
});
pulled += 1;
}
}
-
- const groups = await client
- .listSubscriptionGroups(appIdStr)
- .catch((error) => {
- failures.push({
- productId: "(asc list groups)",
- reason: error instanceof Error ? error.message : String(error),
- });
- return null;
- });
- if (groups) {
- for (const group of groups.data) {
- const subs = await client
- .listSubscriptionsInGroup(group.id)
- .catch((error) => {
- failures.push({
- productId: `(asc list subs in group ${group.id})`,
- reason: error instanceof Error ? error.message : String(error),
- });
- return null;
- });
- if (!subs) continue;
- // Same parallelization as the IAP loop above. Within each
- // sub, price lookup and intro-offer lookup are independent
- // — fire them as a Promise.all to halve the per-item RTT
- // before walking on to the upsert.
- const subResults = await mapWithConcurrency(
- subs.data,
- 6,
- async (sub) => {
- const productId = sub.attributes.productId;
- if (!productId) return null;
- const [pricePoint, introOffers] = await Promise.all([
- client.subCurrentPrice(sub.id),
- client.subIntroductoryOffer(sub.id),
- ]);
- return { sub, productId, pricePoint, introOffers };
- },
- );
- for (const result of subResults) {
- if (!result) continue;
- const { sub, productId, pricePoint, introOffers } = result;
- if (pricePoint instanceof Error) {
- failures.push({
- productId: `${productId} (price lookup)`,
- reason: pricePoint.message,
- });
- }
- const { priceAmountMicros, currency } = parseAssignedPrice(
- pricePoint instanceof Error ? null : pricePoint,
- "subscriptionPricePoint",
- );
- if (introOffers instanceof Error) {
- failures.push({
- productId: `${productId} (offers lookup)`,
- reason: introOffers.message,
- });
- }
- const offers = parseIntroOffers(
- introOffers instanceof Error ? null : introOffers,
- );
- await ctx.runMutation(internal.products.sync.upsertFromStore, {
- projectId: project._id,
- productId,
- platform: "IOS",
- type: "Subscription",
- title: sub.attributes.name ?? productId,
- priceAmountMicros,
- currency,
- storeRef: sub.id,
- state: mapAscState(sub.attributes.state),
- billingPeriod: coerceBillingPeriod(
- mapAscOfferDurationToIso(
- sub.attributes.subscriptionPeriod ?? undefined,
- ),
- ),
- subscriptionGroupId: group.id,
- subscriptionGroupName: group.attributes.referenceName,
- offers: offers.length ? offers : undefined,
- });
- pulled += 1;
- }
- }
- }
}
+ }
- // ── PUSH: kit → ASC for Draft rows ─────────────────────────────
- // Each draft becomes a multi-step flow: create → localize → set
- // price. The first step alone leaves the IAP/sub in an unsubmittable
- // state because Apple requires both an en-US localization and a
- // USA price schedule before the row can move past Draft. We do
- // the whole chain here so a single Sync click takes the catalog
- // from "kit-only" to "Ready to Submit" in App Store Connect.
- // Submission itself (screenshot upload + inAppPurchaseSubmissions
- // POST) is a follow-up because it needs a screenshot file and a
- // dashboard upload slot we haven't built yet — see TODO below.
- if (direction === "push" || direction === "both") {
- const drafts = await ctx.runQuery(
- internal.products.sync.listDraftIosProducts,
- { projectId: project._id },
- );
- // Cache subscriptionGroup find-or-create results across the
- // entire push pass so a project with multiple drafts in the
- // same group (Premium Monthly + Premium Yearly + Premium
- // Weekly all referencing groupName="Premium") only triggers
- // one ASC listSubscriptionGroups round-trip — and never two
- // concurrent create calls racing for the same name.
+ // ── PUSH: kit → ASC for Draft rows ─────────────────────────────
+ // Each draft becomes a multi-step flow: create → localize → set
+ // price. The first step alone leaves the IAP/sub in an unsubmittable
+ // state because Apple requires both an en-US localization and a
+ // USA price schedule before the row can move past Draft. We do
+ // the whole chain here so a single Sync click takes the catalog
+ // from "kit-only" to "Ready to Submit" in App Store Connect.
+ // Submission itself (screenshot upload + inAppPurchaseSubmissions
+ // POST) is a follow-up because it needs a screenshot file and a
+ // dashboard upload slot we haven't built yet — see TODO below.
+ if (direction === "push" || direction === "both") {
+ await checkCancelled();
+ await reportPhase("push-drafts", {
+ current: pulled,
+ failuresCount: failures.length,
+ });
+ const drafts = await ctx.runQuery(
+ internal.products.sync.listDraftIosProducts,
+ { projectId: project._id },
+ );
+ // Cache subscriptionGroup find-or-create results across the
+ // entire push pass so a project with multiple drafts in the
+ // same group (Premium Monthly + Premium Yearly + Premium
+ // Weekly all referencing groupName="Premium") only triggers
+ // one ASC listSubscriptionGroups round-trip — and never two
+ // concurrent create calls racing for the same name.
+ //
+ // Stores the in-flight promise (not the resolved id) so two
+ // drafts that hit the same name concurrently share one ASC
+ // round-trip. Without this the parallel push fan-out below
+ // could race two find-or-create calls for the same group,
+ // ending up with one of them returning a 409.
+ const groupIdCache = new Map>();
+ // Dry-run uses a single up-front listSubscriptionGroups fetch
+ // (read-only) so the per-draft preview rendering doesn't
+ // re-list the groups for each Subscription row in drafts.
+ // Lazy: only fetched on the first Subscription draft we hit
+ // in dry-run, so projects without Sub drafts don't pay the
+ // call at all.
+ let dryRunGroupsCache: Awaited<
+ ReturnType
+ > | null = null;
+ const ensureDryRunGroups = async () => {
+ if (!dryRunGroupsCache) {
+ dryRunGroupsCache = await client.listSubscriptionGroups(appIdStr);
+ }
+ return dryRunGroupsCache;
+ };
+ // Bounded-parallel push. ASC throttles aggressively on the
+ // mutation endpoints (createSubscription / createInAppPurchase /
+ // setPriceSchedule) so the previous sequential `for (const row
+ // of drafts)` loop was the safe-but-slow path; a project with
+ // 20 draft products waited 20× the per-draft round-trip. Run
+ // PUSH_CONCURRENCY drafts in parallel and trade some risk of a
+ // 429 (where ASC returns Retry-After we'd surface to the
+ // failures array) for an N× speedup.
+ //
+ // Each draft's create → localize → setPrice steps stay strictly
+ // sequential within `processOneDraft` — ASC rejects ordering
+ // races on a single resource (a localize call landing before
+ // the create propagates returns 409). Cross-draft parallelism
+ // is safe because each upstream resource is independent. The
+ // groupIdCache holds in-flight promises so concurrent drafts in
+ // the same subscription group still issue exactly one
+ // findOrCreate call.
+ //
+ // Concurrency=4 keeps us well under ASC's per-app rate limit
+ // (anecdotally ~10 writes/sec before 429s start) while
+ // delivering ~4× wall-clock improvement on typical catalogs.
+ // mapWithConcurrency preserves input order for the result
+ // array (we don't actually use it; failures + pushed are
+ // accumulated by mutation).
+ const PUSH_CONCURRENCY = 4;
+ const processOneDraft = async (
+ row: (typeof drafts)[number],
+ ): Promise => {
+ // Track failures pushed *for this row* via a row-local flag.
+ // The previous `failuresAtStart = failures.length` snapshot
+ // worked when this loop was sequential, but with
+ // mapWithConcurrency (PUSH_CONCURRENCY=4) the shared
+ // `failures` array can grow because of OTHER concurrent
+ // drafts between the snapshot and the success-gate check —
+ // which would block this draft from calling markPushed even
+ // though every step for THIS row succeeded.
//
- // Stores the in-flight promise (not the resolved id) so two
- // drafts that hit the same name concurrently share one ASC
- // round-trip. Without this the parallel push fan-out below
- // could race two find-or-create calls for the same group,
- // ending up with one of them returning a 409.
- const groupIdCache = new Map>();
- // Dry-run uses a single up-front listSubscriptionGroups fetch
- // (read-only) so the per-draft preview rendering doesn't
- // re-list the groups for each Subscription row in drafts.
- // Lazy: only fetched on the first Subscription draft we hit
- // in dry-run, so projects without Sub drafts don't pay the
- // call at all.
- let dryRunGroupsCache: Awaited<
- ReturnType
- > | null = null;
- const ensureDryRunGroups = async () => {
- if (!dryRunGroupsCache) {
- dryRunGroupsCache = await client.listSubscriptionGroups(appIdStr);
- }
- return dryRunGroupsCache;
+ // Use a row-local boolean + a recordFailure helper so each
+ // draft's success gate is independent of cross-draft noise.
+ // A partial setup (create succeeded, localization failed)
+ // still leaves the row in Draft with a populated storeRef
+ // so the next sync resumes step 2 instead of re-creating
+ // the upstream resource.
+ let rowHadFailure = false;
+ const recordFailure = (failure: {
+ productId: string;
+ reason: string;
+ }) => {
+ rowHadFailure = true;
+ failures.push(failure);
};
- // Bounded-parallel push. ASC throttles aggressively on the
- // mutation endpoints (createSubscription / createInAppPurchase /
- // setPriceSchedule) so the previous sequential `for (const row
- // of drafts)` loop was the safe-but-slow path; a project with
- // 20 draft products waited 20× the per-draft round-trip. Run
- // PUSH_CONCURRENCY drafts in parallel and trade some risk of a
- // 429 (where ASC returns Retry-After we'd surface to the
- // failures array) for an N× speedup.
- //
- // Each draft's create → localize → setPrice steps stay strictly
- // sequential within `processOneDraft` — ASC rejects ordering
- // races on a single resource (a localize call landing before
- // the create propagates returns 409). Cross-draft parallelism
- // is safe because each upstream resource is independent. The
- // groupIdCache holds in-flight promises so concurrent drafts in
- // the same subscription group still issue exactly one
- // findOrCreate call.
- //
- // Concurrency=4 keeps us well under ASC's per-app rate limit
- // (anecdotally ~10 writes/sec before 429s start) while
- // delivering ~4× wall-clock improvement on typical catalogs.
- // mapWithConcurrency preserves input order for the result
- // array (we don't actually use it; failures + pushed are
- // accumulated by mutation).
- const PUSH_CONCURRENCY = 4;
- const processOneDraft = async (
- row: (typeof drafts)[number],
- ): Promise => {
- // Track failures pushed *for this row* via a row-local flag.
- // The previous `failuresAtStart = failures.length` snapshot
- // worked when this loop was sequential, but with
- // mapWithConcurrency (PUSH_CONCURRENCY=4) the shared
- // `failures` array can grow because of OTHER concurrent
- // drafts between the snapshot and the success-gate check —
- // which would block this draft from calling markPushed even
- // though every step for THIS row succeeded.
- //
- // Use a row-local boolean + a recordFailure helper so each
- // draft's success gate is independent of cross-draft noise.
- // A partial setup (create succeeded, localization failed)
- // still leaves the row in Draft with a populated storeRef
- // so the next sync resumes step 2 instead of re-creating
- // the upstream resource.
- let rowHadFailure = false;
- const recordFailure = (failure: {
- productId: string;
- reason: string;
- }) => {
- rowHadFailure = true;
- failures.push(failure);
- };
- try {
- if (row.type === "Subscription") {
- // Resolve the ASC subscriptionGroup from the operator-typed
- // `subscriptionGroupName`. Find-or-create so the operator
- // doesn't have to pre-create the group in ASC's web UI; if
- // they don't pick a name we default to the productId so
- // there's *some* group rather than a hard failure — but
- // surface a non-fatal warning since per-product groups
- // fragment the catalog and break StoreKit 2's
- // upgrade/downgrade flow between Monthly and Yearly tiers
- // (those need to share a group). In dry-run, list groups
- // (read-only) and report which path the real run would
- // take instead of creating anything.
- //
- // Skip both group-resolve and create when this row already
- // has a storeRef from a prior partially-successful sync —
- // re-creating would either duplicate or 409 against ASC.
- const groupName = row.subscriptionGroupName ?? row.productId;
- if (!row.subscriptionGroupName && !row.storeRef && dryRun) {
- // Surface the per-product-group warning in dry-run only
- // so operators see the recommendation while previewing
- // (the most common time to fix the catalog), but a
- // production sync isn't blocked or noisy. Pushing into
- // `failures` would also trip the markPushed gate added
- // for partial-failure resilience.
+ try {
+ if (row.type === "Subscription") {
+ // Resolve the ASC subscriptionGroup from the operator-typed
+ // `subscriptionGroupName`. Find-or-create so the operator
+ // doesn't have to pre-create the group in ASC's web UI; if
+ // they don't pick a name we default to the productId so
+ // there's *some* group rather than a hard failure — but
+ // surface a non-fatal warning since per-product groups
+ // fragment the catalog and break StoreKit 2's
+ // upgrade/downgrade flow between Monthly and Yearly tiers
+ // (those need to share a group). In dry-run, list groups
+ // (read-only) and report which path the real run would
+ // take instead of creating anything.
+ //
+ // Skip both group-resolve and create when this row already
+ // has a storeRef from a prior partially-successful sync —
+ // re-creating would either duplicate or 409 against ASC.
+ const groupName = row.subscriptionGroupName ?? row.productId;
+ if (!row.subscriptionGroupName && !row.storeRef && dryRun) {
+ // Surface the per-product-group warning in dry-run only
+ // so operators see the recommendation while previewing
+ // (the most common time to fix the catalog), but a
+ // production sync isn't blocked or noisy. Pushing into
+ // `failures` would also trip the markPushed gate added
+ // for partial-failure resilience.
+ plannedWrites.push({
+ productId: row.productId,
+ step: "warning: no subscription group name set",
+ detail:
+ "Falling back to productId so this sub lands in its own group. Pick a shared name (e.g. 'Premium') for related tiers so StoreKit 2 upgrade/downgrade works.",
+ });
+ }
+ let storeRef: string;
+ if (row.storeRef) {
+ storeRef = row.storeRef;
+ if (dryRun) {
plannedWrites.push({
productId: row.productId,
- step: "warning: no subscription group name set",
- detail:
- "Falling back to productId so this sub lands in its own group. Pick a shared name (e.g. 'Premium') for related tiers so StoreKit 2 upgrade/downgrade works.",
+ step: "skip create (resuming partial sync)",
+ detail: `existing storeRef=${storeRef}`,
});
}
- let storeRef: string;
- if (row.storeRef) {
- storeRef = row.storeRef;
- if (dryRun) {
- plannedWrites.push({
- productId: row.productId,
- step: "skip create (resuming partial sync)",
- detail: `existing storeRef=${storeRef}`,
- });
- }
+ } else {
+ let groupId: string;
+ if (dryRun) {
+ const groups = await ensureDryRunGroups();
+ const existing = groups.data.find(
+ (g) => g.attributes.referenceName === groupName,
+ );
+ groupId = existing?.id ?? "(would-create)";
+ plannedWrites.push({
+ productId: row.productId,
+ step: existing
+ ? "use existing subscription group"
+ : "create subscription group",
+ detail: groupName,
+ });
+ storeRef = "(would-create)";
+ plannedWrites.push({
+ productId: row.productId,
+ step: "create subscription",
+ detail: `${row.title} · ${mapBillingPeriodToAsc(row.billingPeriod)} · group=${groupName}`,
+ });
} else {
- let groupId: string;
- if (dryRun) {
- const groups = await ensureDryRunGroups();
- const existing = groups.data.find(
- (g) => g.attributes.referenceName === groupName,
- );
- groupId = existing?.id ?? "(would-create)";
- plannedWrites.push({
- productId: row.productId,
- step: existing
- ? "use existing subscription group"
- : "create subscription group",
- detail: groupName,
+ let cached = groupIdCache.get(groupName);
+ if (!cached) {
+ cached = client.findOrCreateSubscriptionGroup({
+ appId: appIdStr,
+ referenceName: groupName,
});
- storeRef = "(would-create)";
- plannedWrites.push({
- productId: row.productId,
- step: "create subscription",
- detail: `${row.title} · ${mapBillingPeriodToAsc(row.billingPeriod)} · group=${groupName}`,
- });
- } else {
- let cached = groupIdCache.get(groupName);
- if (!cached) {
- cached = client.findOrCreateSubscriptionGroup({
- appId: appIdStr,
- referenceName: groupName,
- });
- groupIdCache.set(groupName, cached);
- // If the in-flight call rejects, evict the cached
- // promise so a follow-up draft can retry instead of
- // permanently inheriting the failure.
- cached.catch(() => {
- if (groupIdCache.get(groupName) === cached) {
- groupIdCache.delete(groupName);
- }
- });
- }
- groupId = await cached;
- const result = await client.createSubscription({
- groupId,
- productId: row.productId,
- name: row.title,
- subscriptionPeriod: mapBillingPeriodToAsc(row.billingPeriod),
- reviewNote: row.reviewNote,
+ groupIdCache.set(groupName, cached);
+ // If the in-flight call rejects, evict the cached
+ // promise so a follow-up draft can retry instead of
+ // permanently inheriting the failure.
+ cached.catch(() => {
+ if (groupIdCache.get(groupName) === cached) {
+ groupIdCache.delete(groupName);
+ }
});
- storeRef = result.data.id;
- // Persist the upstream id immediately so a subsequent
- // step's failure doesn't lose the binding (and the
- // next sync sees this row's storeRef populated and
- // skips the create call above).
- await ctx.runMutation(internal.products.sync.markStoreRef, {
- projectId: project._id,
- productId: row.productId,
- platform: "IOS",
- storeRef,
+ }
+ groupId = await cached;
+ const result = await client.createSubscription({
+ groupId,
+ productId: row.productId,
+ name: row.title,
+ subscriptionPeriod: mapBillingPeriodToAsc(row.billingPeriod),
+ reviewNote: row.reviewNote,
+ });
+ storeRef = result.data.id;
+ // Persist the upstream id immediately so a subsequent
+ // step's failure doesn't lose the binding (and the
+ // next sync sees this row's storeRef populated and
+ // skips the create call above).
+ await ctx.runMutation(internal.products.sync.markStoreRef, {
+ projectId: project._id,
+ productId: row.productId,
+ platform: "IOS",
+ storeRef,
+ });
+ }
+ }
+ // Localize so reviewers see the human-readable name +
+ // description instead of just the productId. ASC requires
+ // at least one locale before submission — failing here
+ // doesn't unwind the create (Apple has no rollback) so we
+ // record a failure and let the operator retry / fix in
+ // ASC web.
+ if (dryRun) {
+ plannedWrites.push({
+ productId: row.productId,
+ step: "create en-US localization",
+ detail: row.description ?? row.title,
+ });
+ } else {
+ try {
+ await client.createSubLocalization({
+ subId: storeRef,
+ name: row.title,
+ description: row.description ?? row.title,
+ });
+ } catch (error) {
+ // 409 Conflict means the en-US localization already
+ // exists from a prior partial sync. That's a benign
+ // retry — fall through to the price-setting step
+ // instead of marking the whole product failed.
+ if (!(error instanceof AscApiError && error.status === 409)) {
+ recordFailure({
+ productId: `${row.productId} (localization)`,
+ reason:
+ error instanceof Error ? error.message : String(error),
});
}
}
- // Localize so reviewers see the human-readable name +
- // description instead of just the productId. ASC requires
- // at least one locale before submission — failing here
- // doesn't unwind the create (Apple has no rollback) so we
- // record a failure and let the operator retry / fix in
- // ASC web.
+ }
+ // Set the USA price by resolving the operator's USD amount
+ // → Apple's nearest price-point id. We require currency =
+ // "USD" because the dashboard form lets them pick others
+ // but we only know the USA tier ladder here; non-USD prices
+ // are surfaced as an actionable failure rather than silently
+ // mis-priced. In dry-run, skip the lookup (the just-created
+ // subscription resource doesn't exist for read-back) and
+ // just record intent.
+ if (
+ row.priceAmountMicros !== undefined &&
+ (row.currency ?? "USD") === "USD"
+ ) {
if (dryRun) {
plannedWrites.push({
productId: row.productId,
- step: "create en-US localization",
- detail: row.description ?? row.title,
+ step: "set USA price",
+ detail: `USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)}`,
});
} else {
try {
- await client.createSubLocalization({
- subId: storeRef,
- name: row.title,
- description: row.description ?? row.title,
- });
+ const pricePointId = await client.findSubUsaPricePointId(
+ storeRef,
+ row.priceAmountMicros,
+ );
+ if (!pricePointId) {
+ recordFailure({
+ productId: `${row.productId} (price)`,
+ reason: `No ASC price tier matches USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)} — pick a published tier amount.`,
+ });
+ } else {
+ await client.setSubPriceSchedule({
+ subId: storeRef,
+ pricePointId,
+ });
+ }
} catch (error) {
- // 409 Conflict means the en-US localization already
- // exists from a prior partial sync. That's a benign
- // retry — fall through to the price-setting step
- // instead of marking the whole product failed.
+ // 409 Conflict means a price schedule already exists
+ // for the (subscription, startDate=today) pair from a
+ // prior partial sync — Apple keys schedules by date,
+ // not by id. Treat as benign retry so the subsequent
+ // markPushed step still runs (PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124)
+ // review).
if (!(error instanceof AscApiError && error.status === 409)) {
recordFailure({
- productId: `${row.productId} (localization)`,
+ productId: `${row.productId} (price)`,
reason:
error instanceof Error ? error.message : String(error),
});
}
}
}
- // Set the USA price by resolving the operator's USD amount
- // → Apple's nearest price-point id. We require currency =
- // "USD" because the dashboard form lets them pick others
- // but we only know the USA tier ladder here; non-USD prices
- // are surfaced as an actionable failure rather than silently
- // mis-priced. In dry-run, skip the lookup (the just-created
- // subscription resource doesn't exist for read-back) and
- // just record intent.
- if (
- row.priceAmountMicros !== undefined &&
- (row.currency ?? "USD") === "USD"
- ) {
- if (dryRun) {
- plannedWrites.push({
- productId: row.productId,
- step: "set USA price",
- detail: `USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)}`,
- });
- } else {
- try {
- const pricePointId = await client.findSubUsaPricePointId(
- storeRef,
- row.priceAmountMicros,
- );
- if (!pricePointId) {
- recordFailure({
- productId: `${row.productId} (price)`,
- reason: `No ASC price tier matches USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)} — pick a published tier amount.`,
- });
- } else {
- await client.setSubPriceSchedule({
- subId: storeRef,
- pricePointId,
- });
- }
- } catch (error) {
- // 409 Conflict means a price schedule already exists
- // for the (subscription, startDate=today) pair from a
- // prior partial sync — Apple keys schedules by date,
- // not by id. Treat as benign retry so the subsequent
- // markPushed step still runs (PR #124
- // (https://github.com/hyodotdev/openiap/pull/124)
- // review).
- if (!(error instanceof AscApiError && error.status === 409)) {
- recordFailure({
- productId: `${row.productId} (price)`,
- reason:
- error instanceof Error ? error.message : String(error),
- });
- }
- }
- }
- } else if (row.currency && row.currency !== "USD") {
- recordFailure({
- productId: `${row.productId} (price)`,
- reason: `Non-USD pricing (${row.currency}) not supported in push yet — set USD on the catalog row or configure other territories in ASC web.`,
- });
- }
- // Only flip state to Ready when every follow-up step
- // succeeded. Partial setups stay in Draft (with storeRef
- // populated) so the next sync resumes the missing pieces.
- if (!dryRun && !rowHadFailure) {
- await ctx.runMutation(internal.products.sync.markPushed, {
- projectId: project._id,
+ } else if (row.currency && row.currency !== "USD") {
+ recordFailure({
+ productId: `${row.productId} (price)`,
+ reason: `Non-USD pricing (${row.currency}) not supported in push yet — set USD on the catalog row or configure other territories in ASC web.`,
+ });
+ }
+ // Only flip state to Ready when every follow-up step
+ // succeeded. Partial setups stay in Draft (with storeRef
+ // populated) so the next sync resumes the missing pieces.
+ if (!dryRun && !rowHadFailure) {
+ await ctx.runMutation(internal.products.sync.markPushed, {
+ projectId: project._id,
+ productId: row.productId,
+ platform: "IOS",
+ storeRef,
+ });
+ }
+ pushed += 1;
+ } else {
+ let storeRef: string;
+ if (row.storeRef) {
+ storeRef = row.storeRef;
+ if (dryRun) {
+ plannedWrites.push({
productId: row.productId,
- platform: "IOS",
- storeRef,
+ step: "skip create (resuming partial sync)",
+ detail: `existing storeRef=${storeRef}`,
});
}
- pushed += 1;
+ } else if (dryRun) {
+ storeRef = "(would-create)";
+ plannedWrites.push({
+ productId: row.productId,
+ step: "create in-app purchase",
+ detail: `${row.title} · ${row.type}`,
+ });
} else {
- let storeRef: string;
- if (row.storeRef) {
- storeRef = row.storeRef;
- if (dryRun) {
- plannedWrites.push({
- productId: row.productId,
- step: "skip create (resuming partial sync)",
- detail: `existing storeRef=${storeRef}`,
- });
- }
- } else if (dryRun) {
- storeRef = "(would-create)";
- plannedWrites.push({
- productId: row.productId,
- step: "create in-app purchase",
- detail: `${row.title} · ${row.type}`,
- });
- } else {
- const result = await client.createInAppPurchase({
- appId: appIdStr,
- productId: row.productId,
+ const result = await client.createInAppPurchase({
+ appId: appIdStr,
+ productId: row.productId,
+ name: row.title,
+ type: row.type === "Consumable" ? "CONSUMABLE" : "NON_CONSUMABLE",
+ reviewNote: row.reviewNote,
+ });
+ storeRef = result.data.id;
+ // Same partial-sync resilience as the Subscription
+ // branch — persist the upstream id before the
+ // localization / price steps that may fail.
+ await ctx.runMutation(internal.products.sync.markStoreRef, {
+ projectId: project._id,
+ productId: row.productId,
+ platform: "IOS",
+ storeRef,
+ });
+ }
+ if (dryRun) {
+ plannedWrites.push({
+ productId: row.productId,
+ step: "create en-US localization",
+ detail: row.description ?? row.title,
+ });
+ } else {
+ try {
+ await client.createIapLocalization({
+ iapId: storeRef,
name: row.title,
- type:
- row.type === "Consumable" ? "CONSUMABLE" : "NON_CONSUMABLE",
- reviewNote: row.reviewNote,
- });
- storeRef = result.data.id;
- // Same partial-sync resilience as the Subscription
- // branch — persist the upstream id before the
- // localization / price steps that may fail.
- await ctx.runMutation(internal.products.sync.markStoreRef, {
- projectId: project._id,
- productId: row.productId,
- platform: "IOS",
- storeRef,
+ description: row.description ?? row.title,
});
+ } catch (error) {
+ // Same 409-is-benign rationale as the subscription
+ // localization path — see PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124) review.
+ if (!(error instanceof AscApiError && error.status === 409)) {
+ recordFailure({
+ productId: `${row.productId} (localization)`,
+ reason:
+ error instanceof Error ? error.message : String(error),
+ });
+ }
}
+ }
+ if (
+ row.priceAmountMicros !== undefined &&
+ (row.currency ?? "USD") === "USD"
+ ) {
if (dryRun) {
plannedWrites.push({
productId: row.productId,
- step: "create en-US localization",
- detail: row.description ?? row.title,
+ step: "set USA price",
+ detail: `USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)}`,
});
} else {
try {
- await client.createIapLocalization({
- iapId: storeRef,
- name: row.title,
- description: row.description ?? row.title,
- });
+ const pricePointId = await client.findIapUsaPricePointId(
+ storeRef,
+ row.priceAmountMicros,
+ );
+ if (!pricePointId) {
+ recordFailure({
+ productId: `${row.productId} (price)`,
+ reason: `No ASC price tier matches USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)} — pick a published tier amount.`,
+ });
+ } else {
+ await client.setIapPriceSchedule({
+ iapId: storeRef,
+ pricePointId,
+ });
+ }
} catch (error) {
// Same 409-is-benign rationale as the subscription
- // localization path — see PR #124
- // (https://github.com/hyodotdev/openiap/pull/124) review.
+ // price schedule path above — Apple keys IAP price
+ // schedules by (iapId, startDate) so a same-day
+ // retry hits Conflict. Allow the row to proceed to
+ // markPushed instead of stalling in Draft.
if (!(error instanceof AscApiError && error.status === 409)) {
recordFailure({
- productId: `${row.productId} (localization)`,
+ productId: `${row.productId} (price)`,
reason:
error instanceof Error ? error.message : String(error),
});
}
}
}
- if (
- row.priceAmountMicros !== undefined &&
- (row.currency ?? "USD") === "USD"
- ) {
- if (dryRun) {
- plannedWrites.push({
- productId: row.productId,
- step: "set USA price",
- detail: `USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)}`,
- });
- } else {
- try {
- const pricePointId = await client.findIapUsaPricePointId(
- storeRef,
- row.priceAmountMicros,
- );
- if (!pricePointId) {
- recordFailure({
- productId: `${row.productId} (price)`,
- reason: `No ASC price tier matches USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)} — pick a published tier amount.`,
- });
- } else {
- await client.setIapPriceSchedule({
- iapId: storeRef,
- pricePointId,
- });
- }
- } catch (error) {
- // Same 409-is-benign rationale as the subscription
- // price schedule path above — Apple keys IAP price
- // schedules by (iapId, startDate) so a same-day
- // retry hits Conflict. Allow the row to proceed to
- // markPushed instead of stalling in Draft.
- if (!(error instanceof AscApiError && error.status === 409)) {
- recordFailure({
- productId: `${row.productId} (price)`,
- reason:
- error instanceof Error ? error.message : String(error),
- });
- }
- }
- }
- } else if (row.currency && row.currency !== "USD") {
- recordFailure({
- productId: `${row.productId} (price)`,
- reason: `Non-USD pricing (${row.currency}) not supported in push yet — set USD on the catalog row or configure other territories in ASC web.`,
- });
- }
- // Same gate as the Subscription branch — only flip Ready
- // when no follow-up step recorded a failure for this row.
- if (!dryRun && !rowHadFailure) {
- await ctx.runMutation(internal.products.sync.markPushed, {
- projectId: project._id,
- productId: row.productId,
- platform: "IOS",
- storeRef,
- });
- }
- pushed += 1;
+ } else if (row.currency && row.currency !== "USD") {
+ recordFailure({
+ productId: `${row.productId} (price)`,
+ reason: `Non-USD pricing (${row.currency}) not supported in push yet — set USD on the catalog row or configure other territories in ASC web.`,
+ });
}
- // TODO(review-submit): once Settings has an upload slot for a
- // project-level App Review screenshot
- // (`apple_iap_review_screenshot` purpose), add a step here:
- // 1. POST /v1/inAppPurchaseAppStoreReviewScreenshots (reserve)
- // 2. PUT to the returned upload URL (binary)
- // 3. PATCH ...screenshots/{id} with sourceFileChecksum
- // 4. POST /v1/inAppPurchaseSubmissions
- // Until then, the row stops at "Ready to Submit" in ASC and
- // the operator hits Submit manually (or via next app version).
- } catch (error) {
- recordFailure({
- productId: row.productId,
- reason: error instanceof Error ? error.message : String(error),
- });
+ // Same gate as the Subscription branch — only flip Ready
+ // when no follow-up step recorded a failure for this row.
+ if (!dryRun && !rowHadFailure) {
+ await ctx.runMutation(internal.products.sync.markPushed, {
+ projectId: project._id,
+ productId: row.productId,
+ platform: "IOS",
+ storeRef,
+ });
+ }
+ pushed += 1;
}
- };
- await mapWithConcurrency(drafts, PUSH_CONCURRENCY, processOneDraft);
- }
-
- return {
- pulled,
- pushed,
- failures,
- plannedWrites: dryRun ? plannedWrites : undefined,
+ // TODO(review-submit): once Settings has an upload slot for a
+ // project-level App Review screenshot
+ // (`apple_iap_review_screenshot` purpose), add a step here:
+ // 1. POST /v1/inAppPurchaseAppStoreReviewScreenshots (reserve)
+ // 2. PUT to the returned upload URL (binary)
+ // 3. PATCH ...screenshots/{id} with sourceFileChecksum
+ // 4. POST /v1/inAppPurchaseSubmissions
+ // Until then, the row stops at "Ready to Submit" in ASC and
+ // the operator hits Submit manually (or via next app version).
+ } catch (error) {
+ recordFailure({
+ productId: row.productId,
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ }
};
- },
-});
+ await mapWithConcurrency(drafts, PUSH_CONCURRENCY, processOneDraft);
+ }
+
+ return {
+ pulled,
+ pushed,
+ failures,
+ plannedWrites: dryRun ? plannedWrites : undefined,
+ };
+}
// Lightweight read-only action so the dashboard can populate a
// subscription-group autocomplete without the operator having to copy
diff --git a/packages/kit/convex/products/jobs.test.ts b/packages/kit/convex/products/jobs.test.ts
new file mode 100644
index 00000000..15164937
--- /dev/null
+++ b/packages/kit/convex/products/jobs.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ PRODUCT_SYNC_FAILURES_CAP,
+ PRODUCT_SYNC_FAILED_RETENTION_MS,
+ PRODUCT_SYNC_JOB_DEADLINE_MS,
+ PRODUCT_SYNC_REAPER_GRACE_MS,
+ PRODUCT_SYNC_SUCCEEDED_RETENTION_MS,
+ truncateFailures,
+} from "./jobs";
+
+describe("truncateFailures", () => {
+ it("returns the original array unchanged when under the cap", () => {
+ const failures = Array.from({ length: 10 }, (_, i) => ({
+ productId: `p${i}`,
+ reason: "boom",
+ }));
+ const { items, truncated } = truncateFailures(failures);
+ expect(items).toBe(failures);
+ expect(truncated).toBe(false);
+ });
+
+ it("caps the array and flips the flag when over", () => {
+ const failures = Array.from(
+ { length: PRODUCT_SYNC_FAILURES_CAP + 50 },
+ (_, i) => ({ productId: `p${i}`, reason: "boom" }),
+ );
+ const { items, truncated } = truncateFailures(failures);
+ expect(items.length).toBe(PRODUCT_SYNC_FAILURES_CAP);
+ expect(items[0]?.productId).toBe("p0");
+ expect(truncated).toBe(true);
+ });
+
+ it("preserves order when truncating", () => {
+ const failures = Array.from(
+ { length: PRODUCT_SYNC_FAILURES_CAP + 1 },
+ (_, i) => ({ productId: `p${i}`, reason: "boom" }),
+ );
+ const { items } = truncateFailures(failures);
+ for (let i = 0; i < items.length; i += 1) {
+ expect(items[i]?.productId).toBe(`p${i}`);
+ }
+ });
+});
+
+describe("retention constants", () => {
+ // Sanity-check the bounds the reaper / pruner crons rely on.
+ // Without these the worker timeout is meaningless and the pruner
+ // could delete failed jobs before the operator can read them.
+ it("reaper grace is shorter than the worker deadline", () => {
+ expect(PRODUCT_SYNC_REAPER_GRACE_MS).toBeLessThan(
+ PRODUCT_SYNC_JOB_DEADLINE_MS,
+ );
+ });
+
+ it("failed retention outlives succeeded retention", () => {
+ expect(PRODUCT_SYNC_FAILED_RETENTION_MS).toBeGreaterThan(
+ PRODUCT_SYNC_SUCCEEDED_RETENTION_MS,
+ );
+ });
+});
diff --git a/packages/kit/convex/products/jobs.ts b/packages/kit/convex/products/jobs.ts
new file mode 100644
index 00000000..e5349a58
--- /dev/null
+++ b/packages/kit/convex/products/jobs.ts
@@ -0,0 +1,487 @@
+import { v } from "convex/values";
+import { getAuthUserId } from "@convex-dev/auth/server";
+
+import {
+ internalAction,
+ internalMutation,
+ internalQuery,
+ mutation,
+ query,
+ type QueryCtx,
+} from "../_generated/server";
+import { internal } from "../_generated/api";
+import type { Doc, Id } from "../_generated/dataModel";
+import { ErrorCode, createError } from "../utils/errors";
+
+// Per-job hard ceiling. Convex actions cap at ~10min; we allow 9min
+// for the worker and rely on the reaper to mark anything still
+// running 1min past that as failed.
+export const PRODUCT_SYNC_JOB_DEADLINE_MS = 9 * 60 * 1_000;
+export const PRODUCT_SYNC_REAPER_GRACE_MS = 60 * 1_000;
+export const PRODUCT_SYNC_SUCCEEDED_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
+export const PRODUCT_SYNC_FAILED_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
+export const PRODUCT_SYNC_FAILURES_CAP = 200;
+
+// Cap the failures array stored on the job row so a runaway sync
+// (every product fails for the same upstream config reason) doesn't
+// blow past Convex's per-document size budget. The dashboard sees
+// `failuresTruncated: true` and renders a notice; the operator can
+// re-run after fixing the root cause.
+export function truncateFailures<
+ T extends { productId: string; reason: string },
+>(failures: T[]): { items: T[]; truncated: boolean } {
+ if (failures.length <= PRODUCT_SYNC_FAILURES_CAP) {
+ return { items: failures, truncated: false };
+ }
+ return {
+ items: failures.slice(0, PRODUCT_SYNC_FAILURES_CAP),
+ truncated: true,
+ };
+}
+
+const platformValidator = v.union(v.literal("IOS"), v.literal("Android"));
+const directionValidator = v.union(
+ v.literal("pull"),
+ v.literal("push"),
+ v.literal("both"),
+ v.literal("purge-local"),
+);
+
+async function requireProjectMember(
+ ctx: QueryCtx,
+ apiKey: string,
+): Promise<{ project: Doc<"projects">; userId: Id<"users"> }> {
+ const userId = await getAuthUserId(ctx);
+ if (!userId) {
+ throw createError(ErrorCode.NOT_AUTHENTICATED);
+ }
+ const project = await ctx.db
+ .query("projects")
+ .withIndex("by_api_key", (q) => q.eq("apiKey", apiKey))
+ .first();
+ if (!project) {
+ throw createError(ErrorCode.PROJECT_NOT_FOUND);
+ }
+ const membership = await ctx.db
+ .query("organizationMembers")
+ .withIndex("by_org_and_user", (q) =>
+ q.eq("organizationId", project.organizationId).eq("userId", userId),
+ )
+ .first();
+ if (!membership) {
+ throw createError(ErrorCode.NOT_ORGANIZATION_MEMBER);
+ }
+ return { project, userId };
+}
+
+async function requireJobAccess(
+ ctx: QueryCtx,
+ jobId: Id<"productSyncJobs">,
+): Promise<{ job: Doc<"productSyncJobs">; userId: Id<"users"> }> {
+ const userId = await getAuthUserId(ctx);
+ if (!userId) {
+ throw createError(ErrorCode.NOT_AUTHENTICATED);
+ }
+ const job = await ctx.db.get(jobId);
+ if (!job) {
+ throw createError(ErrorCode.INVALID_INPUT, "Sync job not found");
+ }
+ const project = await ctx.db.get(job.projectId);
+ if (!project) {
+ throw createError(ErrorCode.PROJECT_NOT_FOUND);
+ }
+ const membership = await ctx.db
+ .query("organizationMembers")
+ .withIndex("by_org_and_user", (q) =>
+ q.eq("organizationId", project.organizationId).eq("userId", userId),
+ )
+ .first();
+ if (!membership) {
+ throw createError(ErrorCode.NOT_ORGANIZATION_MEMBER);
+ }
+ return { job, userId };
+}
+
+// Latest job (any status) for a project+platform — drives the
+// dashboard's button state, progress, and last-result toast.
+export const getActiveSyncJob = query({
+ args: {
+ apiKey: v.string(),
+ platform: platformValidator,
+ },
+ handler: async (ctx, args) => {
+ const { project } = await requireProjectMember(ctx, args.apiKey);
+ return await ctx.db
+ .query("productSyncJobs")
+ .withIndex("by_project_and_created", (q) =>
+ q.eq("projectId", project._id),
+ )
+ .filter((q) => q.eq(q.field("platform"), args.platform))
+ .order("desc")
+ .first();
+ },
+});
+
+export const getSyncJobById = query({
+ args: { jobId: v.id("productSyncJobs") },
+ handler: async (ctx, args) => {
+ const { job } = await requireJobAccess(ctx, args.jobId);
+ return job;
+ },
+});
+
+// Idempotent enqueue: if there's already a queued/running job for this
+// (project, platform) we return the existing jobId so a double-tap or
+// page reload doesn't fan out duplicate workers.
+export const enqueueProductSync = mutation({
+ args: {
+ apiKey: v.string(),
+ platform: platformValidator,
+ direction: v.optional(directionValidator),
+ dryRun: v.optional(v.boolean()),
+ },
+ returns: v.object({
+ jobId: v.id("productSyncJobs"),
+ deduped: v.boolean(),
+ }),
+ handler: async (ctx, args) => {
+ const { project, userId } = await requireProjectMember(ctx, args.apiKey);
+ const existingActive = await ctx.db
+ .query("productSyncJobs")
+ .withIndex("by_project_platform_status", (q) =>
+ q
+ .eq("projectId", project._id)
+ .eq("platform", args.platform)
+ .eq("status", "queued"),
+ )
+ .first();
+ const existingRunning = await ctx.db
+ .query("productSyncJobs")
+ .withIndex("by_project_platform_status", (q) =>
+ q
+ .eq("projectId", project._id)
+ .eq("platform", args.platform)
+ .eq("status", "running"),
+ )
+ .first();
+ const existing = existingActive ?? existingRunning;
+ if (existing) {
+ return { jobId: existing._id, deduped: true };
+ }
+ const now = Date.now();
+ const jobId = await ctx.db.insert("productSyncJobs", {
+ projectId: project._id,
+ platform: args.platform,
+ direction: args.direction ?? "both",
+ dryRun: args.dryRun ?? false,
+ status: "queued",
+ progress: { phase: "queued" },
+ createdBy: userId,
+ createdAt: now,
+ });
+ if (args.direction === "purge-local") {
+ // Purge runs in this module's V8-isolate runtime — no Apple
+ // credentials, no Play OAuth, no `"use node"` cost. Just a
+ // bounded delete loop against `products`.
+ await ctx.scheduler.runAfter(
+ 0,
+ internal.products.jobs.runProductSyncPurgeLocal,
+ { jobId },
+ );
+ } else if (args.platform === "IOS") {
+ await ctx.scheduler.runAfter(0, internal.products.asc.runProductSyncIOS, {
+ jobId,
+ });
+ } else {
+ await ctx.scheduler.runAfter(
+ 0,
+ internal.products.play.runProductSyncAndroid,
+ { jobId },
+ );
+ }
+ return { jobId, deduped: false };
+ },
+});
+
+// Worker for `direction: "purge-local"`. Empties kit's local
+// `products` rows for the (project, platform) in page-bounded
+// batches; never touches App Store Connect or Play Console. The
+// next regular sync re-pulls from the upstream store, so this is
+// the recovery hatch when kit's cache drifts (manual store edits,
+// failed partial pushes, stale prices). Cancel checks between
+// pages so an operator can stop a runaway wipe within seconds.
+export const runProductSyncPurgeLocal = internalAction({
+ args: { jobId: v.id("productSyncJobs") },
+ handler: async (ctx, args): Promise => {
+ const job = await ctx.runQuery(internal.products.jobs.getJobForWorker, {
+ jobId: args.jobId,
+ });
+ if (!job) return;
+ if (job.status !== "queued") return;
+ await ctx.runMutation(internal.products.jobs.markJobRunning, {
+ jobId: args.jobId,
+ });
+ try {
+ const PAGE = 100;
+ let total = 0;
+ // 200-page guard caps a runaway loop at 20k rows — far past
+ // any real project's catalog. The bounded `take(limit + 1)`
+ // inside `deletePlatformCatalog` decides `hasMore` from the
+ // overflow row, so we exit cleanly the moment the page returns
+ // short.
+ for (let page = 0; page < 200; page += 1) {
+ const cancelled = await ctx.runQuery(
+ internal.products.jobs.isCancelRequested,
+ { jobId: args.jobId },
+ );
+ if (cancelled) {
+ await ctx.runMutation(internal.products.jobs.markJobFailed, {
+ jobId: args.jobId,
+ error: "Cancelled by operator",
+ });
+ return;
+ }
+ await ctx.runMutation(internal.products.jobs.updateJobProgress, {
+ jobId: args.jobId,
+ phase: "purge-local",
+ current: total,
+ });
+ const { deleted, hasMore } = await ctx.runMutation(
+ internal.products.sync.deletePlatformCatalog,
+ {
+ projectId: job.projectId,
+ platform: job.platform,
+ limit: PAGE,
+ },
+ );
+ total += deleted;
+ if (!hasMore) break;
+ }
+ await ctx.runMutation(internal.products.jobs.markJobSucceeded, {
+ jobId: args.jobId,
+ pulled: 0,
+ pushed: 0,
+ deleted: total,
+ failures: [],
+ });
+ } catch (error) {
+ await ctx.runMutation(internal.products.jobs.markJobFailed, {
+ jobId: args.jobId,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ },
+});
+
+// Operator-initiated cancel. The worker checks `cancelRequested` at
+// phase boundaries — granularity is per-phase, not per-product, but
+// that's enough to stop a runaway sync within seconds on most paths.
+export const cancelProductSync = mutation({
+ args: { jobId: v.id("productSyncJobs") },
+ handler: async (ctx, args) => {
+ const { job } = await requireJobAccess(ctx, args.jobId);
+ if (job.status !== "queued" && job.status !== "running") {
+ return { ok: false, reason: "not active" as const };
+ }
+ await ctx.db.patch(job._id, { cancelRequested: true });
+ return { ok: true as const };
+ },
+});
+
+// Soft-dismiss a finished job from the dashboard. Doesn't delete the
+// row (the pruner handles retention) — just makes a future
+// `getActiveSyncJob` skip it.
+//
+// Implemented via a tombstone field instead of a status change so
+// audit-style queries can still see succeeded/failed history.
+export const dismissCompletedJob = mutation({
+ args: { jobId: v.id("productSyncJobs") },
+ handler: async (ctx, args) => {
+ const { job } = await requireJobAccess(ctx, args.jobId);
+ if (job.status !== "succeeded" && job.status !== "failed") {
+ return { ok: false as const };
+ }
+ // Reuse `cancelRequested` as a dismissal marker would muddle
+ // semantics; instead we shift `completedAt` into the past so the
+ // active-job query (ordered desc by createdAt) still sees newer
+ // jobs while the dashboard filter on the client treats this row
+ // as "dismissed" via `dismissed: true`.
+ await ctx.db.patch(job._id, {
+ progress: { ...job.progress, phase: "dismissed" },
+ });
+ return { ok: true as const };
+ },
+});
+
+// Worker-side helpers (internal — only the runner actions call these).
+
+export const getJobForWorker = internalQuery({
+ args: { jobId: v.id("productSyncJobs") },
+ handler: async (ctx, args) => {
+ return await ctx.db.get(args.jobId);
+ },
+});
+
+export const isCancelRequested = internalQuery({
+ args: { jobId: v.id("productSyncJobs") },
+ handler: async (ctx, args) => {
+ const job = await ctx.db.get(args.jobId);
+ return job?.cancelRequested === true;
+ },
+});
+
+export const markJobRunning = internalMutation({
+ args: { jobId: v.id("productSyncJobs") },
+ handler: async (ctx, args) => {
+ const job = await ctx.db.get(args.jobId);
+ if (!job) return;
+ if (job.status !== "queued") return;
+ const now = Date.now();
+ await ctx.db.patch(args.jobId, {
+ status: "running",
+ startedAt: now,
+ expectedDeadline: now + PRODUCT_SYNC_JOB_DEADLINE_MS,
+ progress: { phase: "starting" },
+ });
+ },
+});
+
+export const updateJobProgress = internalMutation({
+ args: {
+ jobId: v.id("productSyncJobs"),
+ phase: v.string(),
+ current: v.optional(v.number()),
+ total: v.optional(v.number()),
+ failuresCount: v.optional(v.number()),
+ },
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.jobId, {
+ progress: {
+ phase: args.phase,
+ current: args.current,
+ total: args.total,
+ failuresCount: args.failuresCount,
+ },
+ });
+ },
+});
+
+export const markJobSucceeded = internalMutation({
+ args: {
+ jobId: v.id("productSyncJobs"),
+ pulled: v.number(),
+ pushed: v.number(),
+ deleted: v.optional(v.number()),
+ failures: v.array(v.object({ productId: v.string(), reason: v.string() })),
+ plannedWrites: v.optional(
+ v.array(
+ v.object({
+ productId: v.string(),
+ step: v.string(),
+ detail: v.optional(v.string()),
+ }),
+ ),
+ ),
+ },
+ handler: async (ctx, args) => {
+ const { items: failures, truncated } = truncateFailures(args.failures);
+ await ctx.db.patch(args.jobId, {
+ status: "succeeded",
+ completedAt: Date.now(),
+ progress: {
+ phase: "completed",
+ failuresCount: args.failures.length,
+ },
+ result: {
+ pulled: args.pulled,
+ pushed: args.pushed,
+ ...(args.deleted !== undefined ? { deleted: args.deleted } : {}),
+ failures,
+ ...(truncated ? { failuresTruncated: true } : {}),
+ ...(args.plannedWrites ? { plannedWrites: args.plannedWrites } : {}),
+ },
+ });
+ },
+});
+
+export const markJobFailed = internalMutation({
+ args: {
+ jobId: v.id("productSyncJobs"),
+ error: v.string(),
+ pulled: v.optional(v.number()),
+ pushed: v.optional(v.number()),
+ failures: v.optional(
+ v.array(v.object({ productId: v.string(), reason: v.string() })),
+ ),
+ },
+ handler: async (ctx, args) => {
+ const rawFailures = args.failures ?? [];
+ const { items: failures, truncated } = truncateFailures(rawFailures);
+ await ctx.db.patch(args.jobId, {
+ status: "failed",
+ completedAt: Date.now(),
+ error: args.error,
+ progress: {
+ phase: "failed",
+ failuresCount: failures.length,
+ },
+ result: {
+ pulled: args.pulled ?? 0,
+ pushed: args.pushed ?? 0,
+ failures,
+ ...(truncated ? { failuresTruncated: true } : {}),
+ },
+ });
+ },
+});
+
+// Cron: flip `running` rows whose `expectedDeadline + grace` has
+// passed to `failed("worker timed out")`. Without this, a crashed
+// action permanently pins the project's "active job" slot.
+export const reapStaleProductSyncJobs = internalMutation({
+ args: {},
+ handler: async (ctx) => {
+ const cutoff = Date.now() - PRODUCT_SYNC_REAPER_GRACE_MS;
+ const stale = await ctx.db
+ .query("productSyncJobs")
+ .withIndex("by_status_and_deadline", (q) =>
+ q.eq("status", "running").lt("expectedDeadline", cutoff),
+ )
+ .take(50);
+ for (const job of stale) {
+ await ctx.db.patch(job._id, {
+ status: "failed",
+ completedAt: Date.now(),
+ error: "Worker timed out — sync exceeded the 9-minute ceiling",
+ progress: { phase: "reaped" },
+ });
+ }
+ return { reaped: stale.length };
+ },
+});
+
+// Cron: drop succeeded jobs older than 7d, failed older than 30d.
+export const pruneProductSyncJobs = internalMutation({
+ args: {},
+ handler: async (ctx) => {
+ const now = Date.now();
+ const succeededCutoff = now - PRODUCT_SYNC_SUCCEEDED_RETENTION_MS;
+ const failedCutoff = now - PRODUCT_SYNC_FAILED_RETENTION_MS;
+ const succeeded = await ctx.db
+ .query("productSyncJobs")
+ .withIndex("by_status_and_completed", (q) =>
+ q.eq("status", "succeeded").lt("completedAt", succeededCutoff),
+ )
+ .take(100);
+ const failed = await ctx.db
+ .query("productSyncJobs")
+ .withIndex("by_status_and_completed", (q) =>
+ q.eq("status", "failed").lt("completedAt", failedCutoff),
+ )
+ .take(100);
+ for (const row of [...succeeded, ...failed]) {
+ await ctx.db.delete(row._id);
+ }
+ return { pruned: succeeded.length + failed.length };
+ },
+});
diff --git a/packages/kit/convex/products/play.ts b/packages/kit/convex/products/play.ts
index bc4ebe68..5ff02560 100644
--- a/packages/kit/convex/products/play.ts
+++ b/packages/kit/convex/products/play.ts
@@ -3,11 +3,17 @@ import { v } from "convex/values";
import { google } from "googleapis";
import type { androidpublisher_v3 } from "googleapis";
-import { action } from "../_generated/server";
+import { internalAction, type ActionCtx } from "../_generated/server";
import { internal } from "../_generated/api";
-import { getProjectByApiKey } from "../purchases/shared";
import { coerceBillingPeriod } from "./sync";
+class ProductSyncCancelledError extends Error {
+ constructor() {
+ super("Sync cancelled by operator");
+ this.name = "ProductSyncCancelledError";
+ }
+}
+
/**
* Per-product upstream rejection reported back to the dashboard. Used
* inside `pushSyncProductsGoogle`'s `failures` array; extracted so the
@@ -52,403 +58,513 @@ export interface ProductSyncFailure {
* carrying per-product upstream rejection reasons so the
* dashboard can render them.
*/
-export const pushSyncProductsGoogle = action({
- args: {
- apiKey: v.string(),
- direction: v.optional(
- v.union(v.literal("pull"), v.literal("push"), v.literal("both")),
- ),
- },
- returns: v.object({
- pulled: v.number(),
- pushed: v.number(),
- failures: v.array(v.object({ productId: v.string(), reason: v.string() })),
- }),
- handler: async (
- ctx,
- args,
- ): Promise<{
- pulled: number;
- pushed: number;
- failures: ProductSyncFailure[];
- }> => {
- const project = await getProjectByApiKey(ctx, args.apiKey);
- if (!project.androidPackageName) {
- throw new Error("Project androidPackageName is not configured");
- }
-
- const serviceAccountFile = await ctx.runQuery(
- internal.files.internal.getGooglePlayFileByProjectInternal,
- { projectId: project._id },
- );
- if (!serviceAccountFile) {
- throw new Error(
- "Google Play service account JSON not found — upload it before running push-sync",
+// Worker that drives a single Google Play sync job. See the parallel
+// docstring on `runProductSyncIOS` in `products/asc.ts` — same job
+// lifecycle, same cancel-at-phase-boundary semantics.
+export const runProductSyncAndroid = internalAction({
+ args: { jobId: v.id("productSyncJobs") },
+ handler: async (ctx, args): Promise => {
+ const job = await ctx.runQuery(internal.products.jobs.getJobForWorker, {
+ jobId: args.jobId,
+ });
+ if (!job) return;
+ if (job.status !== "queued") return;
+ await ctx.runMutation(internal.products.jobs.markJobRunning, {
+ jobId: args.jobId,
+ });
+ const checkCancelled = async () => {
+ const cancelled = await ctx.runQuery(
+ internal.products.jobs.isCancelRequested,
+ { jobId: args.jobId },
);
+ if (cancelled) throw new ProductSyncCancelledError();
+ };
+ const reportPhase = async (
+ phase: string,
+ extra?: {
+ current?: number;
+ total?: number;
+ failuresCount?: number;
+ },
+ ) => {
+ await ctx.runMutation(internal.products.jobs.updateJobProgress, {
+ jobId: args.jobId,
+ phase,
+ current: extra?.current,
+ total: extra?.total,
+ failuresCount: extra?.failuresCount,
+ });
+ };
+ if (job.direction === "purge-local") {
+ // enqueue routes purge-local jobs to a different worker; this
+ // branch is unreachable in practice but narrows the type for
+ // the call below.
+ await ctx.runMutation(internal.products.jobs.markJobFailed, {
+ jobId: args.jobId,
+ error: "purge-local routed to wrong worker",
+ });
+ return;
}
- const fileContent = await ctx.runAction(
- internal.files.internal.readFileAsText,
- { fileId: serviceAccountFile._id },
- );
- if (!fileContent?.content) {
- throw new Error("Service account JSON file is unreadable");
- }
- // Wrap the parse so a malformed JSON upload yields an actionable
- // config error ("Service account JSON is invalid") instead of a
- // raw SyntaxError from JSON.parse, which surfaces as a generic
- // 500 with no operator-friendly hint.
- let credentials: Record;
try {
- credentials = JSON.parse(fileContent.content) as Record;
- } catch {
- throw new Error(
- "Service account JSON is invalid — re-upload the file from Google Cloud Console",
- );
+ const result = await performAndroidSync(ctx, {
+ projectId: job.projectId,
+ direction: job.direction,
+ dryRun: job.dryRun,
+ checkCancelled,
+ reportPhase,
+ });
+ await ctx.runMutation(internal.products.jobs.markJobSucceeded, {
+ jobId: args.jobId,
+ pulled: result.pulled,
+ pushed: result.pushed,
+ failures: result.failures,
+ plannedWrites: result.plannedWrites,
+ });
+ } catch (error) {
+ const cancelled = error instanceof ProductSyncCancelledError;
+ const message = cancelled
+ ? "Cancelled by operator"
+ : error instanceof Error
+ ? error.message
+ : String(error);
+ await ctx.runMutation(internal.products.jobs.markJobFailed, {
+ jobId: args.jobId,
+ error: message,
+ });
}
+ },
+});
- const auth = new google.auth.GoogleAuth({
- credentials,
- scopes: ["https://www.googleapis.com/auth/androidpublisher"],
- });
- const androidpublisher = google.androidpublisher({ version: "v3", auth });
- const packageName = project.androidPackageName;
- const direction = args.direction ?? "both";
- const failures: ProductSyncFailure[] = [];
- let pulled = 0;
- let pushed = 0;
+async function performAndroidSync(
+ ctx: ActionCtx,
+ options: {
+ projectId: import("../_generated/dataModel").Id<"projects">;
+ direction: "pull" | "push" | "both";
+ dryRun: boolean;
+ checkCancelled: () => Promise;
+ reportPhase: (
+ phase: string,
+ extra?: {
+ current?: number;
+ total?: number;
+ failuresCount?: number;
+ },
+ ) => Promise;
+ },
+): Promise<{
+ pulled: number;
+ pushed: number;
+ failures: ProductSyncFailure[];
+ plannedWrites?: Array<{ productId: string; step: string; detail?: string }>;
+}> {
+ const project = await ctx.runQuery(
+ internal.projects.internal.getProjectById,
+ { projectId: options.projectId },
+ );
+ if (!project) {
+ throw new Error("Project not found for sync job");
+ }
+ if (!project.androidPackageName) {
+ throw new Error("Project androidPackageName is not configured");
+ }
+ const args = { direction: options.direction };
+ const { checkCancelled, reportPhase } = options;
- // ── PULL: Play → kit ─────────────────────────────────────────
- if (direction === "pull" || direction === "both") {
- // One-time products. Play has TWO catalog APIs and apps live in
- // different ones depending on when/how they were set up:
- //
- // - `inappproducts.list` — legacy v1 endpoint. Apps created
- // before the new monetization framework store products here.
- // - `monetization.onetimeproducts.list` — new endpoint. Apps
- // onboarded under "Manage products" in the modern Play
- // Console store products HERE and `inappproducts.list`
- // silently returns empty for them. (This is why "Sync with
- // Play Console" was only pulling subscriptions for accounts
- // using the new console — the one-time products were
- // invisible to the legacy endpoint.)
- //
- // We hit both, dedupe by SKU, and keep going on either failing
- // — that way an account that lives entirely in one or the other
- // still gets a complete pull instead of failing on the missing
- // half.
- //
- // ORDER MATTERS: hit the new monetization API first so its
- // USD-preferred regional price wins. The legacy
- // `inappproducts.list` only exposes a single `defaultPrice`
- // (whatever currency the merchant set in Play Console — often
- // their home currency) which made products like
- // `dev.hyo.martie.10bulbs` show up as "AED 3.89" on the
- // dashboard for an operator using a Korean Play Console where
- // AED happens to be a regional override. New endpoint runs
- // first; legacy only fills in skus the new endpoint missed.
- const seenOneTimeSkus = new Set();
- try {
- // Defensive guard: the new monetization API isn't surfaced in
- // any typed shape by `googleapis` yet, so we cast through
- // `unknown` and read the (possibly-missing) `onetimeproducts`
- // property. `androidpublisher.monetization` is documented but
- // could change shape in a future SDK release; failing soft
- // (treating it as "no monetization endpoint here") lets the
- // legacy `inappproducts.list` path below still pull what it
- // can instead of bailing the entire pull half-done. The
- // outer try/catch records the failure in the per-product
- // `failures` array so the operator sees something happened.
- const monetizationApi = androidpublisher.monetization as
- | { onetimeproducts?: unknown }
- | undefined;
- const onetime = (
- monetizationApi as unknown as {
- onetimeproducts?: {
- list: (params: {
- packageName: string;
- pageToken?: string;
- }) => Promise<{
- data: {
- oneTimeProducts?: Array<{
- productId?: string;
- listings?: Array<{
- languageCode?: string;
- title?: string;
- description?: string;
- }>;
- purchaseOptions?: Array<{
- state?: string;
- purchaseOptionId?: string;
- buyOption?: { legacyCompatible?: boolean };
- rentOption?: unknown;
- // Pricing lives DIRECTLY on the purchaseOption,
- // NOT nested inside buyOption. The earlier shape
- // (buyOption.regionalPricingAndAvailabilityConfigs)
- // was wrong — every one-time product surfaced
- // with no price because the lookup never matched.
- regionalPricingAndAvailabilityConfigs?: Array<{
- regionCode?: string;
- // Google's enum: "AVAILABLE",
- // "NO_LONGER_AVAILABLE", "AVAILABLE_IF_RELEASED".
- // Stale rows from removed regions still ship
- // back with a price attached, so without this
- // field we'd happily display a price the
- // operator turned off years ago.
- availability?: string;
- price?: {
- currencyCode?: string;
- units?: string;
- nanos?: number;
- };
- }>;
+ const serviceAccountFile = await ctx.runQuery(
+ internal.files.internal.getGooglePlayFileByProjectInternal,
+ { projectId: project._id },
+ );
+ if (!serviceAccountFile) {
+ throw new Error(
+ "Google Play service account JSON not found — upload it before running push-sync",
+ );
+ }
+ const fileContent = await ctx.runAction(
+ internal.files.internal.readFileAsText,
+ { fileId: serviceAccountFile._id },
+ );
+ if (!fileContent?.content) {
+ throw new Error("Service account JSON file is unreadable");
+ }
+ // Wrap the parse so a malformed JSON upload yields an actionable
+ // config error ("Service account JSON is invalid") instead of a
+ // raw SyntaxError from JSON.parse, which surfaces as a generic
+ // 500 with no operator-friendly hint.
+ let credentials: Record;
+ try {
+ credentials = JSON.parse(fileContent.content) as Record;
+ } catch {
+ throw new Error(
+ "Service account JSON is invalid — re-upload the file from Google Cloud Console",
+ );
+ }
+
+ const auth = new google.auth.GoogleAuth({
+ credentials,
+ scopes: ["https://www.googleapis.com/auth/androidpublisher"],
+ });
+ const androidpublisher = google.androidpublisher({ version: "v3", auth });
+ const packageName = project.androidPackageName;
+ const direction = args.direction ?? "both";
+ const dryRun = options.dryRun;
+ const failures: ProductSyncFailure[] = [];
+ // Same shape as the iOS sync — read-only preview accumulator. Each
+ // upstream write the PUSH branch would have made gets pushed onto
+ // this list instead, then surfaced through the toast / result
+ // banner so the operator can verify base-plan duration, region
+ // pricing, etc. before committing.
+ const plannedWrites: Array<{
+ productId: string;
+ step: string;
+ detail?: string;
+ }> = [];
+ let pulled = 0;
+ let pushed = 0;
+
+ // ── PULL: Play → kit ─────────────────────────────────────────
+ if (direction === "pull" || direction === "both") {
+ await checkCancelled();
+ await reportPhase("pull-products");
+ // One-time products. Play has TWO catalog APIs and apps live in
+ // different ones depending on when/how they were set up:
+ //
+ // - `inappproducts.list` — legacy v1 endpoint. Apps created
+ // before the new monetization framework store products here.
+ // - `monetization.onetimeproducts.list` — new endpoint. Apps
+ // onboarded under "Manage products" in the modern Play
+ // Console store products HERE and `inappproducts.list`
+ // silently returns empty for them. (This is why "Sync with
+ // Play Console" was only pulling subscriptions for accounts
+ // using the new console — the one-time products were
+ // invisible to the legacy endpoint.)
+ //
+ // We hit both, dedupe by SKU, and keep going on either failing
+ // — that way an account that lives entirely in one or the other
+ // still gets a complete pull instead of failing on the missing
+ // half.
+ //
+ // ORDER MATTERS: hit the new monetization API first so its
+ // USD-preferred regional price wins. The legacy
+ // `inappproducts.list` only exposes a single `defaultPrice`
+ // (whatever currency the merchant set in Play Console — often
+ // their home currency) which made products like
+ // `dev.hyo.martie.10bulbs` show up as "AED 3.89" on the
+ // dashboard for an operator using a Korean Play Console where
+ // AED happens to be a regional override. New endpoint runs
+ // first; legacy only fills in skus the new endpoint missed.
+ const seenOneTimeSkus = new Set();
+ try {
+ // Defensive guard: the new monetization API isn't surfaced in
+ // any typed shape by `googleapis` yet, so we cast through
+ // `unknown` and read the (possibly-missing) `onetimeproducts`
+ // property. `androidpublisher.monetization` is documented but
+ // could change shape in a future SDK release; failing soft
+ // (treating it as "no monetization endpoint here") lets the
+ // legacy `inappproducts.list` path below still pull what it
+ // can instead of bailing the entire pull half-done. The
+ // outer try/catch records the failure in the per-product
+ // `failures` array so the operator sees something happened.
+ const monetizationApi = androidpublisher.monetization as
+ | { onetimeproducts?: unknown }
+ | undefined;
+ const onetime = (
+ monetizationApi as unknown as {
+ onetimeproducts?: {
+ list: (params: {
+ packageName: string;
+ pageToken?: string;
+ }) => Promise<{
+ data: {
+ oneTimeProducts?: Array<{
+ productId?: string;
+ listings?: Array<{
+ languageCode?: string;
+ title?: string;
+ description?: string;
+ }>;
+ purchaseOptions?: Array<{
+ state?: string;
+ purchaseOptionId?: string;
+ buyOption?: { legacyCompatible?: boolean };
+ rentOption?: unknown;
+ // Pricing lives DIRECTLY on the purchaseOption,
+ // NOT nested inside buyOption. The earlier shape
+ // (buyOption.regionalPricingAndAvailabilityConfigs)
+ // was wrong — every one-time product surfaced
+ // with no price because the lookup never matched.
+ regionalPricingAndAvailabilityConfigs?: Array<{
+ regionCode?: string;
+ // Google's enum: "AVAILABLE",
+ // "NO_LONGER_AVAILABLE", "AVAILABLE_IF_RELEASED".
+ // Stale rows from removed regions still ship
+ // back with a price attached, so without this
+ // field we'd happily display a price the
+ // operator turned off years ago.
+ availability?: string;
+ price?: {
+ currencyCode?: string;
+ units?: string;
+ nanos?: number;
+ };
}>;
}>;
- nextPageToken?: string;
- };
- }>;
- };
- }
- ).onetimeproducts;
- if (onetime?.list) {
- let token: string | undefined;
- let pageCount = 0;
- do {
- const resp = await onetime.list({
- packageName,
- ...(token ? { pageToken: token } : {}),
- });
- for (const product of resp.data.oneTimeProducts ?? []) {
- if (!product.productId) continue;
- if (seenOneTimeSkus.has(product.productId)) continue;
- seenOneTimeSkus.add(product.productId);
- const listing = product.listings?.[0];
- // Walk every purchaseOption × regionalPricingAndAvailabilityConfig
- // (pricing lives on the option, not inside buyOption).
- // Two filters before ranking:
- // - drop regions explicitly NO_LONGER_AVAILABLE so we
- // don't surface stale pricing the operator removed.
- // - require the price to have a `units` field — Google
- // ships zero-priced placeholder rows for some regions
- // and they'd outrank real prices alphabetically.
- // Ranking: regionCode === "US" first (canonical kit
- // display currency, deterministically maps to USD),
- // then any USD-currency region (covers operators who
- // override the US region price into a non-USD currency
- // — rare but possible), then the first remaining region
- // alphabetically by currency for a stable result.
- const priceCandidates: Array<{
- regionCode?: string;
- currencyCode?: string;
- units?: string;
- nanos?: number;
- }> = [];
- for (const opt of product.purchaseOptions ?? []) {
- for (const region of opt.regionalPricingAndAvailabilityConfigs ??
- []) {
- if (region.availability === "NO_LONGER_AVAILABLE") continue;
- if (region.price && typeof region.price.units === "string") {
- priceCandidates.push({
- regionCode: region.regionCode,
- currencyCode: region.price.currencyCode,
- units: region.price.units,
- nanos: region.price.nanos,
- });
- }
- }
- }
- priceCandidates.sort((a, b) =>
- (a.currencyCode ?? "").localeCompare(b.currencyCode ?? ""),
- );
- const preferred =
- priceCandidates.find((p) => p.regionCode === "US") ??
- priceCandidates.find((p) => p.currencyCode === "USD") ??
- priceCandidates[0];
- const priceAmountMicros = preferred
- ? moneyToMicros({
- units: preferred.units,
- nanos: preferred.nanos,
- })
- : undefined;
- await ctx.runMutation(internal.products.sync.upsertFromStore, {
- projectId: project._id,
- productId: product.productId,
- platform: "Android",
- // The new API doesn't carry a "consumable vs.
- // non-consumable" distinction the same way — Play
- // tracks consumption at purchase time. Default to
- // NonConsumable; operators can edit on the kit side.
- type: "NonConsumable",
- title: listing?.title ?? product.productId,
- description: listing?.description ?? undefined,
- priceAmountMicros,
- currency: preferred?.currencyCode ?? undefined,
- storeRef: product.productId,
- state: "Active",
- });
- pulled += 1;
- }
- token = resp.data.nextPageToken ?? undefined;
- pageCount += 1;
- if (pageCount > 50) break;
- } while (token);
- }
- } catch (error) {
- failures.push({
- productId: "(play list onetimeproducts)",
- reason: error instanceof Error ? error.message : String(error),
- });
- }
-
- // Legacy `inappproducts.list` runs SECOND so any sku already
- // surfaced by the new endpoint (with USD-preferred pricing) wins
- // via the dedupe set. Only skus invisible to the new endpoint
- // get filled in here with whatever `defaultPrice` the merchant
- // set in Play Console.
- try {
- let token: string | undefined;
- let pageCount = 0;
- do {
- const oneTimes = await androidpublisher.inappproducts.list({
- packageName,
- ...(token ? { token } : {}),
- });
- for (const product of oneTimes.data.inappproduct ?? []) {
- if (!product.sku) continue;
- if (seenOneTimeSkus.has(product.sku)) continue;
- seenOneTimeSkus.add(product.sku);
- if (product.purchaseType === "subscription") continue;
- await ctx.runMutation(internal.products.sync.upsertFromStore, {
- projectId: project._id,
- productId: product.sku,
- platform: "Android",
- type: mapPlayOneTimeType(product),
- title: pickPlayTitle(product) ?? product.sku,
- description: pickPlayDescription(product),
- priceAmountMicros: parsePlayPriceMicros(product),
- currency: pickPlayCurrency(product),
- storeRef: product.sku,
- state: mapPlayStatus(product.status),
- });
- pulled += 1;
- }
- token = oneTimes.data.tokenPagination?.nextPageToken ?? undefined;
- pageCount += 1;
- if (pageCount > 50) break;
- } while (token);
- } catch (error) {
- // The legacy `inappproducts.list` endpoint is deprecated for
- // newer Play Console accounts and Google now responds with
- // "Please migrate to the new publishing API". That message is
- // expected — the new `monetization.onetimeproducts.list` call
- // above already covers this account — and surfacing it as a
- // failure produces a noisy red toast every Sync. Suppress it
- // when seen; surface anything else.
- const reason = error instanceof Error ? error.message : String(error);
- if (!/migrate to the new publishing API/i.test(reason)) {
- failures.push({
- productId: "(play list inappproducts)",
- reason,
- });
+ }>;
+ nextPageToken?: string;
+ };
+ }>;
+ };
}
- }
-
- try {
+ ).onetimeproducts;
+ if (onetime?.list) {
let token: string | undefined;
let pageCount = 0;
do {
- const subs = await androidpublisher.monetization.subscriptions.list({
+ const resp = await onetime.list({
packageName,
...(token ? { pageToken: token } : {}),
});
- for (const sub of subs.data.subscriptions ?? []) {
- if (!sub.productId) continue;
- const { priceAmountMicros, currency, basePlanId } =
- pickSubBasePlanPrice(sub);
- const offers = collectPlaySubscriptionOffers(sub);
- // Pick the billingPeriod from the *same* base plan whose
- // price we just selected (`basePlanId` returned by
- // pickSubBasePlanPrice). If we can't find that exact plan
- // in `offers`, fall back to the first BasePlan row — but
- // this fallback only triggers when basePlanId is missing,
- // which means the subscription has no price at all.
- // Without the basePlanId match, mixed monthly + yearly
- // products would pair the yearly USD price with the
- // monthly duration and break MRR normalization.
- const billingPeriod = (
- basePlanId
- ? offers.find(
- (o) => o.kind === "BasePlan" && o.id === basePlanId,
- )
- : offers.find((o) => o.kind === "BasePlan")
- )?.duration;
+ for (const product of resp.data.oneTimeProducts ?? []) {
+ if (!product.productId) continue;
+ if (seenOneTimeSkus.has(product.productId)) continue;
+ seenOneTimeSkus.add(product.productId);
+ const listing = product.listings?.[0];
+ // Walk every purchaseOption × regionalPricingAndAvailabilityConfig
+ // (pricing lives on the option, not inside buyOption).
+ // Two filters before ranking:
+ // - drop regions explicitly NO_LONGER_AVAILABLE so we
+ // don't surface stale pricing the operator removed.
+ // - require the price to have a `units` field — Google
+ // ships zero-priced placeholder rows for some regions
+ // and they'd outrank real prices alphabetically.
+ // Ranking: regionCode === "US" first (canonical kit
+ // display currency, deterministically maps to USD),
+ // then any USD-currency region (covers operators who
+ // override the US region price into a non-USD currency
+ // — rare but possible), then the first remaining region
+ // alphabetically by currency for a stable result.
+ const priceCandidates: Array<{
+ regionCode?: string;
+ currencyCode?: string;
+ units?: string;
+ nanos?: number;
+ }> = [];
+ for (const opt of product.purchaseOptions ?? []) {
+ for (const region of opt.regionalPricingAndAvailabilityConfigs ??
+ []) {
+ if (region.availability === "NO_LONGER_AVAILABLE") continue;
+ if (region.price && typeof region.price.units === "string") {
+ priceCandidates.push({
+ regionCode: region.regionCode,
+ currencyCode: region.price.currencyCode,
+ units: region.price.units,
+ nanos: region.price.nanos,
+ });
+ }
+ }
+ }
+ priceCandidates.sort((a, b) =>
+ (a.currencyCode ?? "").localeCompare(b.currencyCode ?? ""),
+ );
+ const preferred =
+ priceCandidates.find((p) => p.regionCode === "US") ??
+ priceCandidates.find((p) => p.currencyCode === "USD") ??
+ priceCandidates[0];
+ const priceAmountMicros = preferred
+ ? moneyToMicros({
+ units: preferred.units,
+ nanos: preferred.nanos,
+ })
+ : undefined;
await ctx.runMutation(internal.products.sync.upsertFromStore, {
projectId: project._id,
- productId: sub.productId,
+ productId: product.productId,
platform: "Android",
- type: "Subscription",
- title: sub.listings?.[0]?.title ?? sub.productId,
- description: sub.listings?.[0]?.description ?? undefined,
+ // The new API doesn't carry a "consumable vs.
+ // non-consumable" distinction the same way — Play
+ // tracks consumption at purchase time. Default to
+ // NonConsumable; operators can edit on the kit side.
+ type: "NonConsumable",
+ title: listing?.title ?? product.productId,
+ description: listing?.description ?? undefined,
priceAmountMicros,
- currency,
- storeRef: sub.productId,
+ currency: preferred?.currencyCode ?? undefined,
+ storeRef: product.productId,
state: "Active",
- billingPeriod: coerceBillingPeriod(billingPeriod),
- // Play has no first-class subscription "group" — base
- // plans on a single subscription product play that role,
- // and we surface them as `offers[].kind === "BasePlan"`
- // rows. Leave the ASC-only group fields unset.
- offers: offers.length ? offers : undefined,
});
pulled += 1;
}
- token = subs.data.nextPageToken ?? undefined;
+ token = resp.data.nextPageToken ?? undefined;
pageCount += 1;
if (pageCount > 50) break;
} while (token);
- } catch (error) {
+ }
+ } catch (error) {
+ failures.push({
+ productId: "(play list onetimeproducts)",
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ }
+
+ // Legacy `inappproducts.list` runs SECOND so any sku already
+ // surfaced by the new endpoint (with USD-preferred pricing) wins
+ // via the dedupe set. Only skus invisible to the new endpoint
+ // get filled in here with whatever `defaultPrice` the merchant
+ // set in Play Console.
+ try {
+ let token: string | undefined;
+ let pageCount = 0;
+ do {
+ const oneTimes = await androidpublisher.inappproducts.list({
+ packageName,
+ ...(token ? { token } : {}),
+ });
+ for (const product of oneTimes.data.inappproduct ?? []) {
+ if (!product.sku) continue;
+ if (seenOneTimeSkus.has(product.sku)) continue;
+ seenOneTimeSkus.add(product.sku);
+ if (product.purchaseType === "subscription") continue;
+ await ctx.runMutation(internal.products.sync.upsertFromStore, {
+ projectId: project._id,
+ productId: product.sku,
+ platform: "Android",
+ type: mapPlayOneTimeType(product),
+ title: pickPlayTitle(product) ?? product.sku,
+ description: pickPlayDescription(product),
+ priceAmountMicros: parsePlayPriceMicros(product),
+ currency: pickPlayCurrency(product),
+ storeRef: product.sku,
+ state: mapPlayStatus(product.status),
+ });
+ pulled += 1;
+ }
+ token = oneTimes.data.tokenPagination?.nextPageToken ?? undefined;
+ pageCount += 1;
+ if (pageCount > 50) break;
+ } while (token);
+ } catch (error) {
+ // The legacy `inappproducts.list` endpoint is deprecated for
+ // newer Play Console accounts and Google now responds with
+ // "Please migrate to the new publishing API". That message is
+ // expected — the new `monetization.onetimeproducts.list` call
+ // above already covers this account — and surfacing it as a
+ // failure produces a noisy red toast every Sync. Suppress it
+ // when seen; surface anything else.
+ const reason = error instanceof Error ? error.message : String(error);
+ if (!/migrate to the new publishing API/i.test(reason)) {
failures.push({
- productId: "(play list subscriptions)",
- reason: error instanceof Error ? error.message : String(error),
+ productId: "(play list inappproducts)",
+ reason,
});
}
}
- // ── PUSH: kit → Play for Draft rows ──────────────────────────
- if (direction === "push" || direction === "both") {
- const drafts = await ctx.runQuery(
- internal.products.sync.listDraftAndroidProducts,
- { projectId: project._id },
- );
- for (const row of drafts) {
- try {
- // When this row already has a storeRef from a prior partial
- // sync, run the appropriate update endpoint instead of the
- // create endpoint — Play returns 409 Conflict on
- // create-with-existing-productId and ASC's parity step
- // (asc.ts) does the same patch flow. Listings + price are
- // both safe to re-push idempotently. Without this, kit-side
- // edits made after the initial push would silently never
- // reach Play (PR #124
+ try {
+ let token: string | undefined;
+ let pageCount = 0;
+ do {
+ const subs = await androidpublisher.monetization.subscriptions.list({
+ packageName,
+ ...(token ? { pageToken: token } : {}),
+ });
+ for (const sub of subs.data.subscriptions ?? []) {
+ if (!sub.productId) continue;
+ const { priceAmountMicros, currency, basePlanId } =
+ pickSubBasePlanPrice(sub);
+ const offers = collectPlaySubscriptionOffers(sub);
+ // Pick the billingPeriod from the *same* base plan whose
+ // price we just selected (`basePlanId` returned by
+ // pickSubBasePlanPrice). If we can't find that exact plan
+ // in `offers`, fall back to the first BasePlan row — but
+ // this fallback only triggers when basePlanId is missing,
+ // which means the subscription has no price at all.
+ // Without the basePlanId match, mixed monthly + yearly
+ // products would pair the yearly USD price with the
+ // monthly duration and break MRR normalization.
+ const billingPeriod = (
+ basePlanId
+ ? offers.find((o) => o.kind === "BasePlan" && o.id === basePlanId)
+ : offers.find((o) => o.kind === "BasePlan")
+ )?.duration;
+ await ctx.runMutation(internal.products.sync.upsertFromStore, {
+ projectId: project._id,
+ productId: sub.productId,
+ platform: "Android",
+ type: "Subscription",
+ title: sub.listings?.[0]?.title ?? sub.productId,
+ description: sub.listings?.[0]?.description ?? undefined,
+ priceAmountMicros,
+ currency,
+ storeRef: sub.productId,
+ state: "Active",
+ billingPeriod: coerceBillingPeriod(billingPeriod),
+ // Play has no first-class subscription "group" — base
+ // plans on a single subscription product play that role,
+ // and we surface them as `offers[].kind === "BasePlan"`
+ // rows. Leave the ASC-only group fields unset.
+ offers: offers.length ? offers : undefined,
+ });
+ pulled += 1;
+ }
+ token = subs.data.nextPageToken ?? undefined;
+ pageCount += 1;
+ if (pageCount > 50) break;
+ } while (token);
+ } catch (error) {
+ failures.push({
+ productId: "(play list subscriptions)",
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+
+ // ── PUSH: kit → Play for Draft rows ──────────────────────────
+ if (direction === "push" || direction === "both") {
+ await checkCancelled();
+ await reportPhase("push-drafts", {
+ current: pulled,
+ failuresCount: failures.length,
+ });
+ const drafts = await ctx.runQuery(
+ internal.products.sync.listDraftAndroidProducts,
+ { projectId: project._id },
+ );
+ for (const row of drafts) {
+ try {
+ // When this row already has a storeRef from a prior partial
+ // sync, run the appropriate update endpoint instead of the
+ // create endpoint — Play returns 409 Conflict on
+ // create-with-existing-productId and ASC's parity step
+ // (asc.ts) does the same patch flow. Listings + price are
+ // both safe to re-push idempotently. Without this, kit-side
+ // edits made after the initial push would silently never
+ // reach Play (PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124) review).
+ if (row.storeRef) {
+ // Track whether the patch step succeeded — only flip to
+ // Ready when the upstream actually accepted our changes,
+ // otherwise the row stays Draft and surfaces in the next
+ // sync's drafts list for retry (PR #124
// (https://github.com/hyodotdev/openiap/pull/124) review).
- if (row.storeRef) {
- // Track whether the patch step succeeded — only flip to
- // Ready when the upstream actually accepted our changes,
- // otherwise the row stays Draft and surfaces in the next
- // sync's drafts list for retry (PR #124
- // (https://github.com/hyodotdev/openiap/pull/124) review).
- let patchOk = true;
- if (row.type === "Subscription") {
- // Subscriptions: patch the listing via
- // monetization.subscriptions.patch (en-US listing only —
- // multi-language sync is a future feature). Base-plan
- // price changes have to go through a separate
- // monetization.subscriptions.basePlans endpoint, so we
- // intentionally don't try to mutate price here; that
- // requires a deactivate+recreate flow Play doesn't allow
- // in a single call. The dashboard surfaces a hint when
- // the kit-side row has a different price than the
- // pulled row so the operator knows to do that step
- // manually.
+ let patchOk = true;
+ if (row.type === "Subscription") {
+ // Subscriptions: patch the listing via
+ // monetization.subscriptions.patch (en-US listing only —
+ // multi-language sync is a future feature). Base-plan
+ // price changes have to go through a separate
+ // monetization.subscriptions.basePlans endpoint, so we
+ // intentionally don't try to mutate price here; that
+ // requires a deactivate+recreate flow Play doesn't allow
+ // in a single call. The dashboard surfaces a hint when
+ // the kit-side row has a different price than the
+ // pulled row so the operator knows to do that step
+ // manually.
+ if (dryRun) {
+ plannedWrites.push({
+ productId: row.productId,
+ step: "patch subscription listing",
+ detail: `${row.title} (en-US, storeRef=${row.storeRef})`,
+ });
+ } else {
try {
await androidpublisher.monetization.subscriptions.patch({
packageName,
@@ -485,10 +601,22 @@ export const pushSyncProductsGoogle = action({
error instanceof Error ? error.message : String(error),
});
}
+ }
+ } else {
+ // One-time product: patch listings + price via the
+ // legacy inappproducts.patch endpoint, which accepts a
+ // partial body and merges it.
+ if (dryRun) {
+ plannedWrites.push({
+ productId: row.productId,
+ step: "patch in-app product",
+ detail:
+ `${row.title} (en-US, storeRef=${row.storeRef})` +
+ (row.priceAmountMicros !== undefined && row.currency
+ ? ` · ${row.currency} ${(row.priceAmountMicros / 1_000_000).toFixed(2)}`
+ : ""),
+ });
} else {
- // One-time product: patch listings + price via the
- // legacy inappproducts.patch endpoint, which accepts a
- // partial body and merges it.
try {
await androidpublisher.inappproducts.patch({
packageName,
@@ -522,30 +650,47 @@ export const pushSyncProductsGoogle = action({
});
}
}
- if (patchOk) {
- await ctx.runMutation(internal.products.sync.markPushed, {
- projectId: project._id,
- productId: row.productId,
- platform: "Android",
- storeRef: row.storeRef,
- });
- pushed += 1;
- }
- continue;
}
- if (row.type === "Subscription") {
- // Reject subscription creates that would land on Play with
- // no base plan: such a subscription is created in a draft
- // state that the Play app cannot purchase, which silently
- // breaks the SDK's `requestPurchase` flow downstream. The
- // operator must provide both a price and currency at
- // minimum so we can synthesize a base plan.
- if (!row.priceAmountMicros || !row.currency) {
- throw new Error(
- "Subscription requires priceAmountMicros + currency to mint a Play base plan; otherwise the product will not be purchasable.",
- );
- }
- const basePlanId = basePlanIdForPeriod(row.billingPeriod);
+ if (patchOk && !dryRun) {
+ await ctx.runMutation(internal.products.sync.markPushed, {
+ projectId: project._id,
+ productId: row.productId,
+ platform: "Android",
+ storeRef: row.storeRef,
+ });
+ pushed += 1;
+ } else if (patchOk && dryRun) {
+ pushed += 1;
+ }
+ continue;
+ }
+ if (row.type === "Subscription") {
+ // Reject subscription creates that would land on Play with
+ // no base plan: such a subscription is created in a draft
+ // state that the Play app cannot purchase, which silently
+ // breaks the SDK's `requestPurchase` flow downstream. The
+ // operator must provide both a price and currency at
+ // minimum so we can synthesize a base plan. Validation
+ // applies to dry-run too — we want the operator to see
+ // this error before attempting a real sync.
+ if (!row.priceAmountMicros || !row.currency) {
+ throw new Error(
+ "Subscription requires priceAmountMicros + currency to mint a Play base plan; otherwise the product will not be purchasable.",
+ );
+ }
+ const basePlanId = basePlanIdForPeriod(row.billingPeriod);
+ if (dryRun) {
+ plannedWrites.push({
+ productId: row.productId,
+ step: "create subscription",
+ detail: `${row.title} · base plan ${basePlanId} · ${row.billingPeriod ?? "P1M"} · ${row.currency} ${(row.priceAmountMicros / 1_000_000).toFixed(2)} (US)`,
+ });
+ plannedWrites.push({
+ productId: row.productId,
+ step: "activate base plan",
+ detail: basePlanId,
+ });
+ } else {
await androidpublisher.monetization.subscriptions.create({
packageName,
productId: row.productId,
@@ -611,6 +756,18 @@ export const pushSyncProductsGoogle = action({
},
},
);
+ }
+ } else {
+ if (dryRun) {
+ plannedWrites.push({
+ productId: row.productId,
+ step: "create in-app product",
+ detail:
+ `${row.title} · ${row.type}` +
+ (row.priceAmountMicros !== undefined && row.currency
+ ? ` · ${row.currency} ${(row.priceAmountMicros / 1_000_000).toFixed(2)}`
+ : " · no price set"),
+ });
} else {
await androidpublisher.inappproducts.insert({
packageName,
@@ -642,6 +799,8 @@ export const pushSyncProductsGoogle = action({
},
});
}
+ }
+ if (!dryRun) {
// Persist storeRef immediately after the create returns,
// BEFORE flipping state to Ready via markPushed. If the
// action times out / crashes between create and markPushed,
@@ -662,19 +821,24 @@ export const pushSyncProductsGoogle = action({
platform: "Android",
storeRef: row.productId,
});
- pushed += 1;
- } catch (error) {
- failures.push({
- productId: row.productId,
- reason: error instanceof Error ? error.message : String(error),
- });
}
+ pushed += 1;
+ } catch (error) {
+ failures.push({
+ productId: row.productId,
+ reason: error instanceof Error ? error.message : String(error),
+ });
}
}
+ }
- return { pulled, pushed, failures };
- },
-});
+ return {
+ pulled,
+ pushed,
+ failures,
+ plannedWrites: dryRun ? plannedWrites : undefined,
+ };
+}
function mapPlayOneTimeType(
product: androidpublisher_v3.Schema$InAppProduct,
diff --git a/packages/kit/convex/products/sync.ts b/packages/kit/convex/products/sync.ts
index 30501863..06c37886 100644
--- a/packages/kit/convex/products/sync.ts
+++ b/packages/kit/convex/products/sync.ts
@@ -346,3 +346,34 @@ export const listDraftAndroidProducts = internalQuery({
}));
},
});
+
+// Bounded delete used by the `purge-local` sync direction. Deletes
+// the project's kit-side product rows for one platform and returns
+// `{ deleted, hasMore }` so the worker can loop until empty without
+// blowing past Convex's per-mutation document budget. Does NOT touch
+// App Store Connect / Play Console — purging upstream is intentionally
+// out of scope (Apple can't fully delete IAPs via API; Play archive
+// risks live-billing breakage). The operator does that in the
+// store's web console if they really need it.
+export const deletePlatformCatalog = internalMutation({
+ args: {
+ projectId: v.id("projects"),
+ platform: platformValidator,
+ limit: v.number(),
+ },
+ returns: v.object({ deleted: v.number(), hasMore: v.boolean() }),
+ handler: async (ctx, args) => {
+ const page = await ctx.db
+ .query("products")
+ .withIndex("by_project_and_platform", (q) =>
+ q.eq("projectId", args.projectId).eq("platform", args.platform),
+ )
+ .take(args.limit + 1);
+ const hasMore = page.length > args.limit;
+ const toDelete = hasMore ? page.slice(0, args.limit) : page;
+ for (const row of toDelete) {
+ await ctx.db.delete(row._id);
+ }
+ return { deleted: toDelete.length, hasMore };
+ },
+});
diff --git a/packages/kit/convex/projects/internal.ts b/packages/kit/convex/projects/internal.ts
index 3777e76a..d8a02e22 100644
--- a/packages/kit/convex/projects/internal.ts
+++ b/packages/kit/convex/projects/internal.ts
@@ -3,7 +3,7 @@ import { v } from "convex/values";
import { internalQuery } from "../_generated/server";
import { Doc } from "../_generated/dataModel";
import { getApiKeyByKey } from "../apiKeys/helpers";
-import { getProjectById } from "./helpers";
+import { getProjectById as getProjectByIdFromDb } from "./helpers";
export const getProjectByApiKey = internalQuery({
args: {
@@ -19,7 +19,7 @@ export const getProjectByApiKey = internalQuery({
if (apiKey.isActive === false) {
return null;
}
- return getProjectById(ctx, apiKey.projectId);
+ return getProjectByIdFromDb(ctx, apiKey.projectId);
}
// Legacy fallback: early projects — or anything created before the
@@ -35,3 +35,14 @@ export const getProjectByApiKey = internalQuery({
return legacyProject ?? null;
},
});
+
+// Direct id-based lookup. Used by the product-sync workers, which
+// resolve the project from a job row's `projectId` instead of the
+// apiKey path so the worker can run without re-entering the api-key
+// table on every poll.
+export const getProjectById = internalQuery({
+ args: { projectId: v.id("projects") },
+ handler: async (ctx, args): Promise | null> => {
+ return await ctx.db.get(args.projectId);
+ },
+});
diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts
index f576aaa4..c6aa3dce 100644
--- a/packages/kit/convex/schema.ts
+++ b/packages/kit/convex/schema.ts
@@ -834,6 +834,86 @@ const schema = defineSchema({
"productId",
])
.index("by_project_and_platform", ["projectId", "platform"]),
+
+ // Async job rows for the product-sync workers
+ // (`runProductSyncIOS` / `runProductSyncAndroid`). Replaces the
+ // original synchronous `pushSyncProductsAppleIOS` / `pushSyncProductsGoogle`
+ // public actions — those held the browser fetch open for the entire
+ // sync (minutes for catalogs >30 SKUs), so iOS Safari aborted with
+ // `TypeError: Load failed` on cellular / backgrounded tabs. The
+ // dashboard now enqueues a job (returns immediately) and subscribes
+ // via `useQuery(getActiveSyncJob)`; the worker runs as a scheduled
+ // internalAction and writes progress / result back to this row.
+ //
+ // Retention: succeeded rows pruned after 7d, failed after 30d
+ // (so operators still have a record after the weekend).
+ // Reaper: `reapStaleProductSyncJobs` cron flips running rows past
+ // `expectedDeadline` to failed("worker timed out") so a crashed
+ // action can't pin the project's "active job" slot forever.
+ productSyncJobs: defineTable({
+ projectId: v.id("projects"),
+ platform: v.union(v.literal("IOS"), v.literal("Android")),
+ direction: v.union(
+ v.literal("pull"),
+ v.literal("push"),
+ v.literal("both"),
+ // Empty kit's local catalog for the platform without touching
+ // the upstream store. The next regular sync re-pulls, so this
+ // is the "blow away the cache" affordance used to recover from
+ // stale state after manual store edits or a bad config push.
+ v.literal("purge-local"),
+ ),
+ dryRun: v.boolean(),
+ status: v.union(
+ v.literal("queued"),
+ v.literal("running"),
+ v.literal("succeeded"),
+ v.literal("failed"),
+ ),
+ progress: v.object({
+ phase: v.string(),
+ current: v.optional(v.number()),
+ total: v.optional(v.number()),
+ failuresCount: v.optional(v.number()),
+ }),
+ result: v.optional(
+ v.object({
+ pulled: v.number(),
+ pushed: v.number(),
+ // Number of kit-side rows the purge-local direction removed.
+ // Always 0 (or unset) for pull/push/both directions.
+ deleted: v.optional(v.number()),
+ failures: v.array(
+ v.object({ productId: v.string(), reason: v.string() }),
+ ),
+ failuresTruncated: v.optional(v.boolean()),
+ plannedWrites: v.optional(
+ v.array(
+ v.object({
+ productId: v.string(),
+ step: v.string(),
+ detail: v.optional(v.string()),
+ }),
+ ),
+ ),
+ }),
+ ),
+ error: v.optional(v.string()),
+ cancelRequested: v.optional(v.boolean()),
+ expectedDeadline: v.optional(v.number()),
+ createdBy: v.optional(v.id("users")),
+ startedAt: v.optional(v.number()),
+ completedAt: v.optional(v.number()),
+ createdAt: v.number(),
+ })
+ // Active job lookup per (project, platform, status). Backs the
+ // dashboard's `getActiveSyncJob` subscription and the
+ // double-enqueue guard inside `enqueueProductSync`.
+ .index("by_project_platform_status", ["projectId", "platform", "status"])
+ .index("by_project_and_created", ["projectId", "createdAt"])
+ // Reaper / pruner scans.
+ .index("by_status_and_deadline", ["status", "expectedDeadline"])
+ .index("by_status_and_completed", ["status", "completedAt"]),
});
export default schema;
diff --git a/packages/kit/server/api/v1/products.ts b/packages/kit/server/api/v1/products.ts
index 717d264a..c613be94 100644
--- a/packages/kit/server/api/v1/products.ts
+++ b/packages/kit/server/api/v1/products.ts
@@ -1,6 +1,7 @@
import { Hono } from "hono";
import { api } from "@/convex";
+import type { Id } from "@/convex";
import { client } from "../../convex";
// Catalog read/write surface mirroring onesub's @onesub/providers
@@ -147,13 +148,20 @@ products.post("/:apiKey/state", async (c) => {
}
});
+// Enqueue an async sync job. Returns `{ jobId, deduped }`
+// immediately. The caller polls `GET .../sync/jobs/:jobId` until
+// `status` is `succeeded` or `failed`. The previous synchronous
+// endpoint held the HTTP connection open for the entire sync, which
+// iOS Safari aborted on cellular / backgrounded tabs as
+// `TypeError: Load failed`.
products.post("/:apiKey/sync/:platform", async (c) => {
const apiKey = c.req.param("apiKey");
- const platform = c.req.param("platform");
+ const platformParam = c.req.param("platform");
const direction =
(c.req.query("direction") as "pull" | "push" | "both" | undefined) ??
"both";
- if (platform !== "ios" && platform !== "android") {
+ const dryRun = c.req.query("dryRun") === "true";
+ if (platformParam !== "ios" && platformParam !== "android") {
return c.json(
{
errors: [
@@ -163,24 +171,78 @@ products.post("/:apiKey/sync/:platform", async (c) => {
400,
);
}
+ const platform: "IOS" | "Android" =
+ platformParam === "ios" ? "IOS" : "Android";
try {
- const result =
- platform === "ios"
- ? await client.action(api.products.asc.pushSyncProductsAppleIOS, {
- apiKey,
- direction,
- })
- : await client.action(api.products.play.pushSyncProductsGoogle, {
- apiKey,
- direction,
- });
+ const result = await client.mutation(api.products.jobs.enqueueProductSync, {
+ apiKey,
+ platform,
+ direction,
+ dryRun,
+ });
+ return c.json(result, 202);
+ } catch (error) {
+ return c.json(
+ {
+ errors: [
+ {
+ code: "PRODUCT_SYNC_ENQUEUE_FAILED",
+ message: error instanceof Error ? error.message : String(error),
+ },
+ ],
+ },
+ 400,
+ );
+ }
+});
+
+// Poll the job state. Clients should backoff (e.g. 3s) between
+// polls; the typical sync finishes in tens of seconds, larger
+// catalogs in 1-2 min. SSE is a future option; polling kept simple
+// for v1.
+products.get("/:apiKey/sync/jobs/:jobId", async (c) => {
+ const jobId = c.req.param("jobId");
+ try {
+ const job = await client.query(api.products.jobs.getSyncJobById, {
+ jobId: jobId as Id<"productSyncJobs">,
+ });
+ if (!job) {
+ return c.json(
+ { errors: [{ code: "NOT_FOUND", message: "Sync job not found" }] },
+ 404,
+ );
+ }
+ return c.json(job);
+ } catch (error) {
+ return c.json(
+ {
+ errors: [
+ {
+ code: "PRODUCT_SYNC_LOOKUP_FAILED",
+ message: error instanceof Error ? error.message : String(error),
+ },
+ ],
+ },
+ 400,
+ );
+ }
+});
+
+// Operator-initiated cancel. The worker checks `cancelRequested`
+// at phase boundaries.
+products.post("/:apiKey/sync/jobs/:jobId/cancel", async (c) => {
+ const jobId = c.req.param("jobId");
+ try {
+ const result = await client.mutation(api.products.jobs.cancelProductSync, {
+ jobId: jobId as Id<"productSyncJobs">,
+ });
return c.json(result);
} catch (error) {
return c.json(
{
errors: [
{
- code: "PRODUCT_SYNC_FAILED",
+ code: "PRODUCT_SYNC_CANCEL_FAILED",
message: error instanceof Error ? error.message : String(error),
},
],
diff --git a/packages/kit/src/components/AuthTransition.tsx b/packages/kit/src/components/AuthTransition.tsx
index a6460d80..ca068dff 100644
--- a/packages/kit/src/components/AuthTransition.tsx
+++ b/packages/kit/src/components/AuthTransition.tsx
@@ -82,7 +82,7 @@ export function AuthTransition() {
-
+
@@ -93,7 +93,7 @@ export function AuthTransition() {
-
+
);
diff --git a/packages/kit/src/components/Tooltip.tsx b/packages/kit/src/components/Tooltip.tsx
new file mode 100644
index 00000000..82e9077b
--- /dev/null
+++ b/packages/kit/src/components/Tooltip.tsx
@@ -0,0 +1,96 @@
+import { ReactNode, useState } from "react";
+import { cn } from "@/lib/utils";
+
+type Side = "top" | "bottom" | "left" | "right";
+type Align = "start" | "center" | "end";
+
+// Lightweight hover/focus tooltip. Self-contained — no Radix /
+// floating-ui dep — because the only thing we need is a positioned
+// popover that opens on `mouseenter` / `focusin` and closes on
+// `mouseleave` / `focusout`. Anchors against the trigger element via
+// the wrapping `relative` div, so the trigger has to be a single
+// inline-block element (a button, a span); avoid wrapping block
+// elements that fight with `absolute` positioning.
+//
+// Use a real component (not a `title=` attribute or inline JSX) so
+// the hover affordance is consistent across the app: same delay, same
+// 320px max-width, same border/padding tokens.
+export function Tooltip({
+ children,
+ content,
+ side = "bottom",
+ align = "end",
+ widthClass = "w-80",
+}: {
+ children: ReactNode;
+ content: ReactNode;
+ side?: Side;
+ align?: Align;
+ widthClass?: string;
+}) {
+ const [open, setOpen] = useState(false);
+ return (
+
);
@@ -427,6 +508,37 @@ type ProductRow = {
updatedAt: number;
};
+// Map a job's `progress.phase` to the button label. Adds counts for
+// the pull phases when available so the operator sees forward motion
+// instead of a static "Syncing…" while the worker walks dozens of
+// products.
+function formatJobPhaseLabel(job: SyncJob | null, storeLabel: string): string {
+ if (!job) return `Syncing with ${storeLabel}…`;
+ const phase = job.progress.phase;
+ const current = job.progress.current;
+ switch (phase) {
+ case "queued":
+ case "starting":
+ return `Queued for ${storeLabel}…`;
+ case "pull-iaps":
+ return `Pulling IAPs from ${storeLabel}…`;
+ case "pull-subscriptions":
+ return `Pulling subscriptions${
+ current !== undefined ? ` (${current})` : ""
+ }…`;
+ case "pull-products":
+ return `Pulling from ${storeLabel}…`;
+ case "push-drafts":
+ return `Pushing drafts to ${storeLabel}…`;
+ case "purge-local":
+ return `Resetting kit catalog${
+ current !== undefined ? ` (${current} deleted)` : ""
+ }…`;
+ default:
+ return `Syncing with ${storeLabel}…`;
+ }
+}
+
// Render a human-readable label for a single offer row. The dashboard
// shows these as small badges under the subscription title so the
// operator can see at a glance which plans/intros a sub carries
@@ -528,19 +640,245 @@ function groupRowsByHierarchy(
return out;
}
+// Dry-run skips every POST/PATCH against the upstream store and
+// returns a `plannedWrites` list the toast/banner renders so the
+// operator can verify group, price tier, base plan, billing
+// period, etc. before committing. Apple's ASC catalog can't be
+// fully deleted (Removed != gone from catalog); Play archive is
+// reversible but still affects live billing. Either way, previewing
+// is the safer first step.
+function DryRunButton({
+ onDryRun,
+ disabled,
+ platform,
+}: {
+ onDryRun: () => void;
+ disabled: boolean;
+ platform: "IOS" | "Android";
+}) {
+ const storeLabel = platform === "IOS" ? "App Store Connect" : "Play Console";
+ const constraint =
+ platform === "IOS"
+ ? "Apple won't let you fully delete an IAP once it's in the catalog, so this is the safety check."
+ : "Play archive is reversible but still affects live billing, so this is the safety check.";
+ return (
+
+
Read-only preview
+
+ Walks {storeLabel} like a real sync but{" "}
+ skips every write (no products created, no
+ listings, no price changes, no base plan activations). Returns a
+ list of writes the real run would have made so you can verify them
+ before committing — {constraint}
+
+ >
+ }
+ >
+
+
+ );
+}
+
+// Meta Horizon doesn't expose a catalog REST API — only
+// `verify_entitlement` (purchase check) and `consume_entitlement`
+// (consumable burn-down) are reachable from the server side. SKU
+// definitions live exclusively in Meta Quest Developer Hub. We
+// surface the constraint here so a Horizon-enabled project's
+// operator doesn't keep looking for a missing "Sync with Meta"
+// button — kit handles entitlements (receipt verification +
+// 6-hour reconciliation cron) but cannot mirror the catalog.
+function HorizonCatalogNotice() {
+ return (
+
+
+
+
Horizon catalog is upstream-only
+
+ Meta doesn't expose a catalog API — manage Quest / Horizon SKUs
+ in Meta Quest Developer Hub. kit verifies Horizon receipts and
+ reconciles subscription entitlements every 6 hours, but the SKU list
+ itself can't be synced.
+
+ );
+}
+
+// Trigger for the local-purge confirm dialog. Tooltip explains the
+// scope (kit cache only, never store) so the operator isn't scared
+// off by the destructive icon. Disabled state covers both "sync in
+// progress" (would race with the worker) and "nothing to purge".
+function ResetCatalogButton({
+ onClick,
+ disabled,
+}: {
+ onClick: () => void;
+ disabled: boolean;
+}) {
+ return (
+
+
Reset local catalog
+
+ Deletes kit's local rows for this platform.{" "}
+ Does not modify App Store Connect / Play Console.{" "}
+ Use this when kit's cache has drifted from the store and you
+ want a clean re-pull. Run Sync after to re-import.
+
+ >
+ }
+ >
+
+
+ );
+}
+
+// Lightweight confirm dialog. Inline (not a shared ) because
+// (a) it's the only confirm dialog in this page and (b) the
+// AuthModal helper isn't exported from `components/`. The warning
+// list here intentionally calls out the two real risks of
+// purge-then-sync: unpushed local edits get overwritten on re-pull,
+// and kit-only Draft rows that never made it to the store
+// disappear permanently.
+function PurgeConfirmDialog({
+ open,
+ platform,
+ rowCount,
+ onClose,
+ onConfirm,
+}: {
+ open: boolean;
+ platform: "IOS" | "Android";
+ rowCount: number;
+ onClose: () => void;
+ onConfirm: () => void;
+}) {
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [open, onClose]);
+ if (!open) return null;
+ const storeLabel = platform === "IOS" ? "App Store Connect" : "Play Console";
+ return (
+
+
+
+
+
+
+
+
+
+
+ Reset local {storeLabel} catalog?
+
+
+ Deletes all {rowCount} kit-side{" "}
+ {platform === "IOS" ? "iOS" : "Android"} row
+ {rowCount === 1 ? "" : "s"}. {storeLabel} itself is not
+ modified.
+
+
+
+
+
+
+
+ Local edits not yet pushed to {storeLabel} (price changes,
+ review notes, titles) will be lost when the
+ next Sync re-pulls the upstream copy.
+
+
+
+
+
+ Draft rows that were never pushed upstream will be{" "}
+ permanently deleted — they don't exist on{" "}
+ {storeLabel} so Sync can't recover them.
+
From acaaa8e8f2e97b1d6cd6bc0f6317014dc7500340 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Tue, 5 May 2026 19:02:13 +0900
Subject: [PATCH 2/3] fix(kit): address Copilot/Gemini/CodeRabbit review on
async product sync
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
packages/kit/convex/files/internal.ts | 17 +-
packages/kit/convex/products/asc.ts | 47 +++---
packages/kit/convex/products/jobs.ts | 153 +++++++++++-------
packages/kit/convex/products/play.ts | 46 +++---
packages/kit/convex/products/sync.ts | 9 ++
packages/kit/convex/schema.ts | 11 ++
packages/kit/server/api/v1/products.ts | 12 +-
packages/kit/src/lib/sentry.ts | 20 ++-
.../auth/organization/project/products.tsx | 14 +-
9 files changed, 219 insertions(+), 110 deletions(-)
diff --git a/packages/kit/convex/files/internal.ts b/packages/kit/convex/files/internal.ts
index 40ba9dac..7e28e5b3 100644
--- a/packages/kit/convex/files/internal.ts
+++ b/packages/kit/convex/files/internal.ts
@@ -186,18 +186,23 @@ export const readFileAsBase64 = internalAction({
// the dashboard's "download .p8" failing). Encode via `btoa` on
// chunked binary strings so the path stays portable to either
// runtime; chunking keeps the call-stack bound below the
- // String.fromCharCode argument limit for files of any size.
+ // `String.fromCharCode` argument limit for files of any size,
+ // and accumulating the chunks in an array before `join("")`
+ // avoids the O(n²) string-concatenation behavior of `binary +=`
+ // on multi-megabyte uploads (Copilot review on PR #127).
const arrayBuffer = await blob.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
const CHUNK = 0x8000;
- let binary = "";
+ const chunks: string[] = [];
for (let i = 0; i < bytes.length; i += CHUNK) {
- binary += String.fromCharCode.apply(
- null,
- bytes.subarray(i, i + CHUNK) as unknown as number[],
+ chunks.push(
+ String.fromCharCode.apply(
+ null,
+ bytes.subarray(i, i + CHUNK) as unknown as number[],
+ ),
);
}
- const base64 = btoa(binary);
+ const base64 = btoa(chunks.join(""));
return {
fileId: file._id,
diff --git a/packages/kit/convex/products/asc.ts b/packages/kit/convex/products/asc.ts
index 06cdc532..a1f47782 100644
--- a/packages/kit/convex/products/asc.ts
+++ b/packages/kit/convex/products/asc.ts
@@ -1010,28 +1010,39 @@ export const runProductSyncIOS = internalAction({
},
});
-async function performIosSync(
- ctx: ActionCtx,
- options: {
- projectId: import("../_generated/dataModel").Id<"projects">;
- direction: "pull" | "push" | "both";
- dryRun: boolean;
- checkCancelled: () => Promise;
- reportPhase: (
- phase: string,
- extra?: {
- current?: number;
- total?: number;
- failuresCount?: number;
- },
- ) => Promise;
- },
-): Promise<{
+// Shared shape for the per-phase progress callback the worker
+// passes into both `performIosSync` and `performAndroidSync`. Pulled
+// out so the two function signatures stay readable when extended
+// (Gemini review on PR #127).
+interface SyncProgressUpdate {
+ current?: number;
+ total?: number;
+ failuresCount?: number;
+}
+type SyncProgressReporter = (
+ phase: string,
+ extra?: SyncProgressUpdate,
+) => Promise;
+
+interface IosSyncOptions {
+ projectId: import("../_generated/dataModel").Id<"projects">;
+ direction: "pull" | "push" | "both";
+ dryRun: boolean;
+ checkCancelled: () => Promise;
+ reportPhase: SyncProgressReporter;
+}
+
+interface SyncResult {
pulled: number;
pushed: number;
failures: Array<{ productId: string; reason: string }>;
plannedWrites?: Array<{ productId: string; step: string; detail?: string }>;
-}> {
+}
+
+async function performIosSync(
+ ctx: ActionCtx,
+ options: IosSyncOptions,
+): Promise {
const project = await ctx.runQuery(
internal.projects.internal.getProjectById,
{ projectId: options.projectId },
diff --git a/packages/kit/convex/products/jobs.ts b/packages/kit/convex/products/jobs.ts
index e5349a58..41336ed1 100644
--- a/packages/kit/convex/products/jobs.ts
+++ b/packages/kit/convex/products/jobs.ts
@@ -21,6 +21,13 @@ export const PRODUCT_SYNC_REAPER_GRACE_MS = 60 * 1_000;
export const PRODUCT_SYNC_SUCCEEDED_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
export const PRODUCT_SYNC_FAILED_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
export const PRODUCT_SYNC_FAILURES_CAP = 200;
+// Batch size for the reaper / pruner crons. Sized to comfortably
+// fit Convex's per-mutation document budget — enough rows that one
+// tick can clear a typical backlog, but not so many that a
+// pathological deployment with thousands of stuck jobs blows past
+// the budget mid-run.
+export const PRODUCT_SYNC_REAPER_BATCH = 50;
+export const PRODUCT_SYNC_PRUNER_BATCH = 100;
// Cap the failures array stored on the job row so a runaway sync
// (every product fails for the same upstream config reason) doesn't
@@ -47,14 +54,19 @@ const directionValidator = v.union(
v.literal("purge-local"),
);
-async function requireProjectMember(
+// Auth gate: presence of a valid `apiKey` is sufficient (matches the
+// existing pattern in `products/mutation.ts upsertProduct` and the
+// HTTP routes in `server/api/v1/products.ts`). The dashboard call
+// path is also authenticated via Convex Auth — when a logged-in
+// user makes the call we record their `userId` on `createdBy` for
+// audit trail, but a missing user (server-to-server HTTP path)
+// must NOT block the call. Earlier versions required
+// `getAuthUserId` and broke the documented HTTP API entirely
+// (Copilot review on PR #127).
+async function resolveProjectByApiKey(
ctx: QueryCtx,
apiKey: string,
-): Promise<{ project: Doc<"projects">; userId: Id<"users"> }> {
- const userId = await getAuthUserId(ctx);
- if (!userId) {
- throw createError(ErrorCode.NOT_AUTHENTICATED);
- }
+): Promise<{ project: Doc<"projects">; userId: Id<"users"> | null }> {
const project = await ctx.db
.query("projects")
.withIndex("by_api_key", (q) => q.eq("apiKey", apiKey))
@@ -62,44 +74,51 @@ async function requireProjectMember(
if (!project) {
throw createError(ErrorCode.PROJECT_NOT_FOUND);
}
- const membership = await ctx.db
- .query("organizationMembers")
- .withIndex("by_org_and_user", (q) =>
- q.eq("organizationId", project.organizationId).eq("userId", userId),
- )
- .first();
- if (!membership) {
- throw createError(ErrorCode.NOT_ORGANIZATION_MEMBER);
+ let userId: Id<"users"> | null = null;
+ try {
+ userId = await getAuthUserId(ctx);
+ } catch {
+ userId = null;
+ }
+ // Best-effort membership confirmation when a user IS present
+ // — refuse to honor a logged-in user calling another org's
+ // apiKey. Without a logged-in user we trust the apiKey as the
+ // sole credential (server-side caller, MCP tool, SDK).
+ if (userId) {
+ const membership = await ctx.db
+ .query("organizationMembers")
+ .withIndex("by_org_and_user", (q) =>
+ q.eq("organizationId", project.organizationId).eq("userId", userId),
+ )
+ .first();
+ if (!membership) {
+ throw createError(ErrorCode.NOT_ORGANIZATION_MEMBER);
+ }
}
return { project, userId };
}
-async function requireJobAccess(
+// Authenticate `(apiKey, jobId)` together: resolve the project from
+// the apiKey, then verify the job belongs to that project. This
+// ensures the apiKey acts as a per-project capability — a stolen
+// jobId from one project can't be cancelled / read by another
+// project's apiKey.
+async function resolveJobByApiKey(
ctx: QueryCtx,
+ apiKey: string,
jobId: Id<"productSyncJobs">,
-): Promise<{ job: Doc<"productSyncJobs">; userId: Id<"users"> }> {
- const userId = await getAuthUserId(ctx);
- if (!userId) {
- throw createError(ErrorCode.NOT_AUTHENTICATED);
- }
+): Promise<{ job: Doc<"productSyncJobs">; project: Doc<"projects"> }> {
+ const { project } = await resolveProjectByApiKey(ctx, apiKey);
const job = await ctx.db.get(jobId);
if (!job) {
throw createError(ErrorCode.INVALID_INPUT, "Sync job not found");
}
- const project = await ctx.db.get(job.projectId);
- if (!project) {
- throw createError(ErrorCode.PROJECT_NOT_FOUND);
- }
- const membership = await ctx.db
- .query("organizationMembers")
- .withIndex("by_org_and_user", (q) =>
- q.eq("organizationId", project.organizationId).eq("userId", userId),
- )
- .first();
- if (!membership) {
- throw createError(ErrorCode.NOT_ORGANIZATION_MEMBER);
+ if (job.projectId !== project._id) {
+ // Treat cross-project lookups as not-found rather than 403 to
+ // avoid leaking job existence across projects.
+ throw createError(ErrorCode.INVALID_INPUT, "Sync job not found");
}
- return { job, userId };
+ return { job, project };
}
// Latest job (any status) for a project+platform — drives the
@@ -110,22 +129,29 @@ export const getActiveSyncJob = query({
platform: platformValidator,
},
handler: async (ctx, args) => {
- const { project } = await requireProjectMember(ctx, args.apiKey);
+ const { project } = await resolveProjectByApiKey(ctx, args.apiKey);
+ // Composite index `by_project_platform_created` narrows the
+ // index range to just this (project, platform) — replaces the
+ // earlier `by_project_and_created` + in-memory `.filter()`
+ // which scanned every job for the project before discarding
+ // the wrong-platform rows (Gemini review).
return await ctx.db
.query("productSyncJobs")
- .withIndex("by_project_and_created", (q) =>
- q.eq("projectId", project._id),
+ .withIndex("by_project_platform_created", (q) =>
+ q.eq("projectId", project._id).eq("platform", args.platform),
)
- .filter((q) => q.eq(q.field("platform"), args.platform))
.order("desc")
.first();
},
});
export const getSyncJobById = query({
- args: { jobId: v.id("productSyncJobs") },
+ args: {
+ apiKey: v.string(),
+ jobId: v.id("productSyncJobs"),
+ },
handler: async (ctx, args) => {
- const { job } = await requireJobAccess(ctx, args.jobId);
+ const { job } = await resolveJobByApiKey(ctx, args.apiKey, args.jobId);
return job;
},
});
@@ -145,7 +171,7 @@ export const enqueueProductSync = mutation({
deduped: v.boolean(),
}),
handler: async (ctx, args) => {
- const { project, userId } = await requireProjectMember(ctx, args.apiKey);
+ const { project, userId } = await resolveProjectByApiKey(ctx, args.apiKey);
const existingActive = await ctx.db
.query("productSyncJobs")
.withIndex("by_project_platform_status", (q) =>
@@ -176,7 +202,7 @@ export const enqueueProductSync = mutation({
dryRun: args.dryRun ?? false,
status: "queued",
progress: { phase: "queued" },
- createdBy: userId,
+ ...(userId ? { createdBy: userId } : {}),
createdAt: now,
});
if (args.direction === "purge-local") {
@@ -277,9 +303,12 @@ export const runProductSyncPurgeLocal = internalAction({
// phase boundaries — granularity is per-phase, not per-product, but
// that's enough to stop a runaway sync within seconds on most paths.
export const cancelProductSync = mutation({
- args: { jobId: v.id("productSyncJobs") },
+ args: {
+ apiKey: v.string(),
+ jobId: v.id("productSyncJobs"),
+ },
handler: async (ctx, args) => {
- const { job } = await requireJobAccess(ctx, args.jobId);
+ const { job } = await resolveJobByApiKey(ctx, args.apiKey, args.jobId);
if (job.status !== "queued" && job.status !== "running") {
return { ok: false, reason: "not active" as const };
}
@@ -288,24 +317,23 @@ export const cancelProductSync = mutation({
},
});
-// Soft-dismiss a finished job from the dashboard. Doesn't delete the
-// row (the pruner handles retention) — just makes a future
-// `getActiveSyncJob` skip it.
-//
-// Implemented via a tombstone field instead of a status change so
-// audit-style queries can still see succeeded/failed history.
+// Soft-dismiss a finished job from the dashboard. Doesn't delete
+// the row (the pruner handles retention) — sets
+// `progress.phase = "dismissed"` so the dashboard's result-banner
+// gate (`progress.phase === "dismissed"`, see products.tsx
+// `` renderer) and the toast effect both hide the
+// row. We avoid `cancelRequested` here so the worker's cancel
+// semantics stay distinct from operator dismiss.
export const dismissCompletedJob = mutation({
- args: { jobId: v.id("productSyncJobs") },
+ args: {
+ apiKey: v.string(),
+ jobId: v.id("productSyncJobs"),
+ },
handler: async (ctx, args) => {
- const { job } = await requireJobAccess(ctx, args.jobId);
+ const { job } = await resolveJobByApiKey(ctx, args.apiKey, args.jobId);
if (job.status !== "succeeded" && job.status !== "failed") {
return { ok: false as const };
}
- // Reuse `cancelRequested` as a dismissal marker would muddle
- // semantics; instead we shift `completedAt` into the past so the
- // active-job query (ordered desc by createdAt) still sees newer
- // jobs while the dashboard filter on the client treats this row
- // as "dismissed" via `dismissed: true`.
await ctx.db.patch(job._id, {
progress: { ...job.progress, phase: "dismissed" },
});
@@ -423,7 +451,12 @@ export const markJobFailed = internalMutation({
error: args.error,
progress: {
phase: "failed",
- failuresCount: failures.length,
+ // Match `markJobSucceeded`'s "true count" semantics —
+ // `progress.failuresCount` is the raw upstream count, not
+ // the truncated slice's length, so analytics readers see
+ // the same number on both terminal states (CodeRabbit
+ // review on PR #127).
+ failuresCount: rawFailures.length,
},
result: {
pulled: args.pulled ?? 0,
@@ -447,7 +480,7 @@ export const reapStaleProductSyncJobs = internalMutation({
.withIndex("by_status_and_deadline", (q) =>
q.eq("status", "running").lt("expectedDeadline", cutoff),
)
- .take(50);
+ .take(PRODUCT_SYNC_REAPER_BATCH);
for (const job of stale) {
await ctx.db.patch(job._id, {
status: "failed",
@@ -472,13 +505,13 @@ export const pruneProductSyncJobs = internalMutation({
.withIndex("by_status_and_completed", (q) =>
q.eq("status", "succeeded").lt("completedAt", succeededCutoff),
)
- .take(100);
+ .take(PRODUCT_SYNC_PRUNER_BATCH);
const failed = await ctx.db
.query("productSyncJobs")
.withIndex("by_status_and_completed", (q) =>
q.eq("status", "failed").lt("completedAt", failedCutoff),
)
- .take(100);
+ .take(PRODUCT_SYNC_PRUNER_BATCH);
for (const row of [...succeeded, ...failed]) {
await ctx.db.delete(row._id);
}
diff --git a/packages/kit/convex/products/play.ts b/packages/kit/convex/products/play.ts
index 5ff02560..00c117b0 100644
--- a/packages/kit/convex/products/play.ts
+++ b/packages/kit/convex/products/play.ts
@@ -135,28 +135,38 @@ export const runProductSyncAndroid = internalAction({
},
});
-async function performAndroidSync(
- ctx: ActionCtx,
- options: {
- projectId: import("../_generated/dataModel").Id<"projects">;
- direction: "pull" | "push" | "both";
- dryRun: boolean;
- checkCancelled: () => Promise;
- reportPhase: (
- phase: string,
- extra?: {
- current?: number;
- total?: number;
- failuresCount?: number;
- },
- ) => Promise;
- },
-): Promise<{
+// See parallel definitions in `products/asc.ts`. Kept inline here
+// (rather than imported across modules) because both files declare
+// `"use node"` and Convex treats the module boundary as the runtime
+// boundary; importing this from a non-`"use node"` module would
+// pull `googleapis` into the V8 isolate runtime (Gemini review on
+// PR #127).
+interface AndroidSyncProgressUpdate {
+ current?: number;
+ total?: number;
+ failuresCount?: number;
+}
+interface AndroidSyncOptions {
+ projectId: import("../_generated/dataModel").Id<"projects">;
+ direction: "pull" | "push" | "both";
+ dryRun: boolean;
+ checkCancelled: () => Promise;
+ reportPhase: (
+ phase: string,
+ extra?: AndroidSyncProgressUpdate,
+ ) => Promise;
+}
+interface AndroidSyncResult {
pulled: number;
pushed: number;
failures: ProductSyncFailure[];
plannedWrites?: Array<{ productId: string; step: string; detail?: string }>;
-}> {
+}
+
+async function performAndroidSync(
+ ctx: ActionCtx,
+ options: AndroidSyncOptions,
+): Promise {
const project = await ctx.runQuery(
internal.projects.internal.getProjectById,
{ projectId: options.projectId },
diff --git a/packages/kit/convex/products/sync.ts b/packages/kit/convex/products/sync.ts
index 06c37886..2264ee9d 100644
--- a/packages/kit/convex/products/sync.ts
+++ b/packages/kit/convex/products/sync.ts
@@ -363,6 +363,15 @@ export const deletePlatformCatalog = internalMutation({
},
returns: v.object({ deleted: v.number(), hasMore: v.boolean() }),
handler: async (ctx, args) => {
+ // Guard against `limit <= 0` — Convex would happily run
+ // `.take(1)` (limit + 1 = 1) and the worker loop reads
+ // `hasMore` to keep iterating, which combined with `deleted = 0`
+ // traps the purge worker in a non-progressing loop until the
+ // 9-min reaper kills it (CodeRabbit critical finding on
+ // PR #127).
+ if (!Number.isInteger(args.limit) || args.limit < 1) {
+ throw new Error("limit must be a positive integer");
+ }
const page = await ctx.db
.query("products")
.withIndex("by_project_and_platform", (q) =>
diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts
index c6aa3dce..beff8890 100644
--- a/packages/kit/convex/schema.ts
+++ b/packages/kit/convex/schema.ts
@@ -910,6 +910,17 @@ const schema = defineSchema({
// dashboard's `getActiveSyncJob` subscription and the
// double-enqueue guard inside `enqueueProductSync`.
.index("by_project_platform_status", ["projectId", "platform", "status"])
+ // Composite (projectId, platform, createdAt) — backs
+ // `getActiveSyncJob` so the latest job per platform is a
+ // direct index range scan instead of "fetch every job for the
+ // project, then in-memory filter by platform". Without this,
+ // the query degraded with the table size on multi-platform
+ // projects (Gemini review on PR #127).
+ .index("by_project_platform_created", [
+ "projectId",
+ "platform",
+ "createdAt",
+ ])
.index("by_project_and_created", ["projectId", "createdAt"])
// Reaper / pruner scans.
.index("by_status_and_deadline", ["status", "expectedDeadline"])
diff --git a/packages/kit/server/api/v1/products.ts b/packages/kit/server/api/v1/products.ts
index c613be94..1d567691 100644
--- a/packages/kit/server/api/v1/products.ts
+++ b/packages/kit/server/api/v1/products.ts
@@ -158,8 +158,12 @@ products.post("/:apiKey/sync/:platform", async (c) => {
const apiKey = c.req.param("apiKey");
const platformParam = c.req.param("platform");
const direction =
- (c.req.query("direction") as "pull" | "push" | "both" | undefined) ??
- "both";
+ (c.req.query("direction") as
+ | "pull"
+ | "push"
+ | "both"
+ | "purge-local"
+ | undefined) ?? "both";
const dryRun = c.req.query("dryRun") === "true";
if (platformParam !== "ios" && platformParam !== "android") {
return c.json(
@@ -201,9 +205,11 @@ products.post("/:apiKey/sync/:platform", async (c) => {
// catalogs in 1-2 min. SSE is a future option; polling kept simple
// for v1.
products.get("/:apiKey/sync/jobs/:jobId", async (c) => {
+ const apiKey = c.req.param("apiKey");
const jobId = c.req.param("jobId");
try {
const job = await client.query(api.products.jobs.getSyncJobById, {
+ apiKey,
jobId: jobId as Id<"productSyncJobs">,
});
if (!job) {
@@ -231,9 +237,11 @@ products.get("/:apiKey/sync/jobs/:jobId", async (c) => {
// Operator-initiated cancel. The worker checks `cancelRequested`
// at phase boundaries.
products.post("/:apiKey/sync/jobs/:jobId/cancel", async (c) => {
+ const apiKey = c.req.param("apiKey");
const jobId = c.req.param("jobId");
try {
const result = await client.mutation(api.products.jobs.cancelProductSync, {
+ apiKey,
jobId: jobId as Id<"productSyncJobs">,
});
return c.json(result);
diff --git a/packages/kit/src/lib/sentry.ts b/packages/kit/src/lib/sentry.ts
index e3acc385..3e710c5d 100644
--- a/packages/kit/src/lib/sentry.ts
+++ b/packages/kit/src/lib/sentry.ts
@@ -50,9 +50,23 @@ if (typeof dsn === "string" && dsn.length > 0 && onAllowedHost) {
// (or downsample) instead of treating each as a fresh signal.
beforeSend: (event, hint) => {
const exception = hint?.originalException;
- const message =
- (exception instanceof Error ? exception.message : String(exception)) ??
- "";
+ // Sentry events occasionally arrive with no `originalException`
+ // (programmatic captureMessage, late-bound rejections that
+ // lost their cause), only an `event.message` /
+ // `logentry.message`. Build the classifier input from every
+ // available source so reconnect noise gets tagged regardless
+ // of where the message lives (CodeRabbit review on PR #127).
+ const message = [
+ exception instanceof Error
+ ? exception.message
+ : typeof exception === "string"
+ ? exception
+ : "",
+ event.message ?? "",
+ event.logentry?.message ?? "",
+ ]
+ .filter(Boolean)
+ .join(" ");
const isFetchLoadFailed =
/Load failed|Failed to fetch|NetworkError/i.test(message);
const looksConvex = /convex\.cloud|\/api\/(action|query|mutation)/i.test(
diff --git a/packages/kit/src/pages/auth/organization/project/products.tsx b/packages/kit/src/pages/auth/organization/project/products.tsx
index 2955e3b0..44494ace 100644
--- a/packages/kit/src/pages/auth/organization/project/products.tsx
+++ b/packages/kit/src/pages/auth/organization/project/products.tsx
@@ -94,6 +94,14 @@ export default function ProjectProducts() {
if (!job) continue;
const terminal = job.status === "succeeded" || job.status === "failed";
if (!terminal) continue;
+ // Once the operator has dismissed a terminal job, the row's
+ // `progress.phase` flips to "dismissed". The result banner
+ // already gates on this; the toast effect needs the same
+ // gate or it re-fires the success/failure toast on every
+ // subsequent page reload (because `lastShownJobIdRef` is
+ // in-memory and resets) until the pruner deletes the row
+ // (CodeRabbit review on PR #127).
+ if (job.progress.phase === "dismissed") continue;
if (lastShownJobIdRef.current[platform] === job._id) continue;
lastShownJobIdRef.current[platform] = job._id;
const label = platform === "IOS" ? "App Store Connect" : "Play Console";
@@ -244,7 +252,7 @@ export default function ProjectProducts() {
const onCancel = async (jobId: SyncJob["_id"], label: string) => {
try {
- await cancelSync({ jobId });
+ await cancelSync({ apiKey: project.apiKey, jobId });
toast.message(`${label} sync — cancellation requested`, {
duration: 4_000,
});
@@ -455,7 +463,7 @@ export default function ProjectProducts() {
void onCancel(jobId, "App Store Connect");
}}
onDismiss={(jobId) => {
- void dismissJob({ jobId });
+ void dismissJob({ apiKey: project.apiKey, jobId });
}}
/>
{
- void dismissJob({ jobId });
+ void dismissJob({ apiKey: project.apiKey, jobId });
}}
/>
From 3c9421f17ccc01f3a57a39d67847e619ee6ba176 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Tue, 5 May 2026 19:25:41 +0900
Subject: [PATCH 3/3] =?UTF-8?q?fix(kit):=20round=202=20review=20=E2=80=94?=
=?UTF-8?q?=20atomic=20dedup,=20USD=20guard,=20Modal=20a11y,=20contrast?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
packages/kit/convex/products/jobs.ts | 93 +++++++---
packages/kit/convex/products/play.ts | 15 ++
packages/kit/convex/schema.ts | 15 ++
.../kit/src/components/AuthModal/index.tsx | 2 +-
.../src/components/{AuthModal => }/Modal.tsx | 38 +++-
packages/kit/src/components/Tooltip.tsx | 68 ++++++-
.../auth/organization/project/products.tsx | 167 +++++++++---------
7 files changed, 278 insertions(+), 120 deletions(-)
rename packages/kit/src/components/{AuthModal => }/Modal.tsx (75%)
diff --git a/packages/kit/convex/products/jobs.ts b/packages/kit/convex/products/jobs.ts
index 41336ed1..37ed84ff 100644
--- a/packages/kit/convex/products/jobs.ts
+++ b/packages/kit/convex/products/jobs.ts
@@ -172,27 +172,31 @@ export const enqueueProductSync = mutation({
}),
handler: async (ctx, args) => {
const { project, userId } = await resolveProjectByApiKey(ctx, args.apiKey);
- const existingActive = await ctx.db
- .query("productSyncJobs")
- .withIndex("by_project_platform_status", (q) =>
- q
- .eq("projectId", project._id)
- .eq("platform", args.platform)
- .eq("status", "queued"),
- )
- .first();
- const existingRunning = await ctx.db
- .query("productSyncJobs")
- .withIndex("by_project_platform_status", (q) =>
- q
- .eq("projectId", project._id)
- .eq("platform", args.platform)
- .eq("status", "running"),
- )
- .first();
- const existing = existingActive ?? existingRunning;
- if (existing) {
- return { jobId: existing._id, deduped: true };
+ // Atomic dedup via the project's `activeSyncJobIds` lock field.
+ // Reading and writing the project doc lets Convex's OCC collapse
+ // two concurrent enqueue mutations onto the same job: both read
+ // the project, both try to patch, only one commit wins; the
+ // loser retries, sees the lock, and returns the deduped jobId
+ // (Copilot review on PR #127).
+ //
+ // Index-only dedup (the prior implementation) wasn't atomic
+ // because the two queries returned `null` for both concurrent
+ // callers, then both inserted separate `productSyncJobs` rows
+ // and scheduled separate workers — fanning out conflicting
+ // upstream writes.
+ const lockedJobId = project.activeSyncJobIds?.[args.platform];
+ if (lockedJobId) {
+ const lockedJob = await ctx.db.get(lockedJobId);
+ if (
+ lockedJob &&
+ (lockedJob.status === "queued" || lockedJob.status === "running")
+ ) {
+ return { jobId: lockedJob._id, deduped: true };
+ }
+ // Lock points at a stale (terminal) row — fall through to
+ // claim a fresh slot. The worker should have cleared it; this
+ // path covers a crashed worker or a job marked failed via the
+ // reaper.
}
const now = Date.now();
const jobId = await ctx.db.insert("productSyncJobs", {
@@ -205,6 +209,12 @@ export const enqueueProductSync = mutation({
...(userId ? { createdBy: userId } : {}),
createdAt: now,
});
+ await ctx.db.patch(project._id, {
+ activeSyncJobIds: {
+ ...(project.activeSyncJobIds ?? {}),
+ [args.platform]: jobId,
+ },
+ });
if (args.direction === "purge-local") {
// Purge runs in this module's V8-isolate runtime — no Apple
// credentials, no Play OAuth, no `"use node"` cost. Just a
@@ -429,6 +439,22 @@ export const markJobSucceeded = internalMutation({
...(args.plannedWrites ? { plannedWrites: args.plannedWrites } : {}),
},
});
+ // Clear the project's lock so the next enqueue can claim the
+ // slot. Guard against a stale lock pointing at a different job
+ // (e.g. a manual db.patch races with a finished worker) so we
+ // never overwrite a fresh lock with `undefined`.
+ const job = await ctx.db.get(args.jobId);
+ if (job) {
+ const project = await ctx.db.get(job.projectId);
+ if (project && project.activeSyncJobIds?.[job.platform] === args.jobId) {
+ await ctx.db.patch(project._id, {
+ activeSyncJobIds: {
+ ...project.activeSyncJobIds,
+ [job.platform]: undefined,
+ },
+ });
+ }
+ }
},
});
@@ -465,6 +491,20 @@ export const markJobFailed = internalMutation({
...(truncated ? { failuresTruncated: true } : {}),
},
});
+ // Mirror `markJobSucceeded` — clear the lock on terminal
+ // failure so the next enqueue isn't blocked by a stuck slot.
+ const job = await ctx.db.get(args.jobId);
+ if (job) {
+ const project = await ctx.db.get(job.projectId);
+ if (project && project.activeSyncJobIds?.[job.platform] === args.jobId) {
+ await ctx.db.patch(project._id, {
+ activeSyncJobIds: {
+ ...project.activeSyncJobIds,
+ [job.platform]: undefined,
+ },
+ });
+ }
+ }
},
});
@@ -488,6 +528,17 @@ export const reapStaleProductSyncJobs = internalMutation({
error: "Worker timed out — sync exceeded the 9-minute ceiling",
progress: { phase: "reaped" },
});
+ // Clear the project's lock so the next enqueue isn't pinned
+ // by the dead worker.
+ const project = await ctx.db.get(job.projectId);
+ if (project && project.activeSyncJobIds?.[job.platform] === job._id) {
+ await ctx.db.patch(project._id, {
+ activeSyncJobIds: {
+ ...project.activeSyncJobIds,
+ [job.platform]: undefined,
+ },
+ });
+ }
}
return { reaped: stale.length };
},
diff --git a/packages/kit/convex/products/play.ts b/packages/kit/convex/products/play.ts
index 00c117b0..6f61f117 100644
--- a/packages/kit/convex/products/play.ts
+++ b/packages/kit/convex/products/play.ts
@@ -688,6 +688,21 @@ async function performAndroidSync(
"Subscription requires priceAmountMicros + currency to mint a Play base plan; otherwise the product will not be purchasable.",
);
}
+ // Play's `regionalConfigs` requires the `currencyCode` to
+ // be the local currency of the `regionCode` it's paired
+ // with — pushing `regionCode: "US"` with a non-USD price
+ // returns a generic 400 from the API and leaves the
+ // operator chasing a confusing error message. Match the
+ // iOS path's currency-validation pattern (asc.ts intro
+ // offer push) and surface an actionable failure here so
+ // the row stays in Draft and the dashboard reports
+ // exactly which SKU failed and why (Gemini review on
+ // PR #127).
+ if (row.currency !== "USD") {
+ throw new Error(
+ `Subscription "${row.productId}" has currency "${row.currency}" but the kit→Play push currently only supports the US region (USD). Set the price in USD on the dashboard, or pre-create the subscription in Play Console with your preferred regional pricing and let the next pull-sync mirror it back into kit.`,
+ );
+ }
const basePlanId = basePlanIdForPeriod(row.billingPeriod);
if (dryRun) {
plannedWrites.push({
diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts
index beff8890..fbe3806a 100644
--- a/packages/kit/convex/schema.ts
+++ b/packages/kit/convex/schema.ts
@@ -219,6 +219,21 @@ const schema = defineSchema({
horizonAppId: v.optional(v.union(v.string(), v.null())),
horizonAppSecret: v.optional(v.union(v.string(), v.null())),
+ // Per-platform "active product-sync job" lock. Read-and-patched
+ // inside `enqueueProductSync` so Convex's optimistic concurrency
+ // control collapses two concurrent enqueue mutations onto the
+ // same job — without this, both readers see an empty
+ // `productSyncJobs` index range, both insert separate queued
+ // rows, and the scheduler fans out two competing workers
+ // (Copilot review on PR #127). Cleared by the worker's
+ // success/failure mutation so the next enqueue can claim it.
+ activeSyncJobIds: v.optional(
+ v.object({
+ IOS: v.optional(v.id("productSyncJobs")),
+ Android: v.optional(v.id("productSyncJobs")),
+ }),
+ ),
+
createdAt: v.number(),
updatedAt: v.number(),
})
diff --git a/packages/kit/src/components/AuthModal/index.tsx b/packages/kit/src/components/AuthModal/index.tsx
index 0dea31ac..3e8e9f46 100644
--- a/packages/kit/src/components/AuthModal/index.tsx
+++ b/packages/kit/src/components/AuthModal/index.tsx
@@ -5,7 +5,7 @@ import { Loader2, Check, AlertCircle, AtSign, Send } from "lucide-react";
import { SiGithub } from "@icons-pack/react-simple-icons";
import { useNavigate } from "react-router-dom";
import { api } from "@/convex";
-import { Modal } from "./Modal";
+import { Modal } from "@/components/Modal";
interface AuthModalProps {
isOpen: boolean;
diff --git a/packages/kit/src/components/AuthModal/Modal.tsx b/packages/kit/src/components/Modal.tsx
similarity index 75%
rename from packages/kit/src/components/AuthModal/Modal.tsx
rename to packages/kit/src/components/Modal.tsx
index 4c7a6c5d..72c02c23 100644
--- a/packages/kit/src/components/AuthModal/Modal.tsx
+++ b/packages/kit/src/components/Modal.tsx
@@ -2,12 +2,27 @@ import React, { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
+// Shared modal primitive. Originally co-located in `AuthModal/Modal.tsx`;
+// promoted here so other dialogs (e.g. the destructive
+// PurgeConfirmDialog on the products page) can reuse the focus
+// trap, escape handling, scroll lock, and focus-restore behavior
+// instead of each rebuilding the same a11y plumbing inline.
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
ariaLabel?: string;
+ // Hide the built-in close (X) button when the consumer supplies
+ // its own dismissal affordance (e.g. a Cancel button in a confirm
+ // dialog). The close button is otherwise nice for the auth
+ // modal's "tap-X" muscle memory.
+ showCloseButton?: boolean;
+ // Override the inner content wrapper's padding. Defaults to
+ // `p-6` to match AuthModal's existing layout; confirm dialogs
+ // typically want a tighter `p-5` or no padding so they can render
+ // their own structured spacing.
+ contentClassName?: string;
}
export function Modal({
@@ -16,6 +31,8 @@ export function Modal({
children,
className = "",
ariaLabel = "Dialog",
+ showCloseButton = true,
+ contentClassName = "p-6",
}: ModalProps) {
const modalRef = useRef(null);
const previouslyFocusedRef = useRef(null);
@@ -116,17 +133,20 @@ export function Modal({
tabIndex={-1}
className={`relative w-full max-w-md bg-white dark:bg-[#18181f] rounded-2xl shadow-2xl animate-scale-in focus:outline-none ${className}`}
>
- {/* Close button */}
-
+ {showCloseButton ? (
+
+ ) : null}
{/* Content */}
-
+
{children}
diff --git a/packages/kit/src/components/Tooltip.tsx b/packages/kit/src/components/Tooltip.tsx
index 82e9077b..6f4de92c 100644
--- a/packages/kit/src/components/Tooltip.tsx
+++ b/packages/kit/src/components/Tooltip.tsx
@@ -1,4 +1,13 @@
-import { ReactNode, useState } from "react";
+import {
+ Children,
+ cloneElement,
+ isValidElement,
+ ReactNode,
+ useId,
+ useState,
+ type FocusEvent,
+ type ReactElement,
+} from "react";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom" | "left" | "right";
@@ -7,14 +16,22 @@ type Align = "start" | "center" | "end";
// Lightweight hover/focus tooltip. Self-contained — no Radix /
// floating-ui dep — because the only thing we need is a positioned
// popover that opens on `mouseenter` / `focusin` and closes on
-// `mouseleave` / `focusout`. Anchors against the trigger element via
-// the wrapping `relative` div, so the trigger has to be a single
-// inline-block element (a button, a span); avoid wrapping block
-// elements that fight with `absolute` positioning.
+// `mouseleave` / `focusout`. Anchors against the trigger element
+// via the wrapping `relative` div.
+//
+// A11y plumbing (Copilot review on PR #127):
+// - The trigger element gets `aria-describedby` pointing at a
+// stable id on the tooltip popover (via `useId` + `cloneElement`),
+// so screen readers announce the tooltip text alongside the
+// trigger's own label.
+// - Focus close uses `onBlurCapture` with a `relatedTarget`
+// containment check — the tooltip stays open while focus moves
+// between descendants of the wrapper instead of closing the
+// moment any inner element is blurred.
//
// Use a real component (not a `title=` attribute or inline JSX) so
-// the hover affordance is consistent across the app: same delay, same
-// 320px max-width, same border/padding tokens.
+// the hover affordance is consistent across the app: same delay,
+// same widths, same border/padding tokens.
export function Tooltip({
children,
content,
@@ -29,17 +46,50 @@ export function Tooltip({
widthClass?: string;
}) {
const [open, setOpen] = useState(false);
+ const tooltipId = useId();
+
+ // Wire `aria-describedby` onto the first valid React element so
+ // assistive tech announces the tooltip text. Falls through
+ // unchanged when the consumer passes a fragment / text node /
+ // multiple siblings — we don't want to silently swallow those
+ // shapes, the user just gets a tooltip without the SR linkage.
+ const trigger =
+ Children.count(children) === 1 && isValidElement(children)
+ ? cloneElement(
+ children as ReactElement<{ "aria-describedby"?: string }>,
+ {
+ "aria-describedby": tooltipId,
+ },
+ )
+ : children;
+
+ const handleBlur = (event: FocusEvent) => {
+ // `relatedTarget` is the element receiving focus next. When it
+ // stays inside the wrapper (e.g. focus moving between two
+ // buttons in the trigger), keep the tooltip open. Closing on
+ // every bubbled blur was prematurely hiding the popover the
+ // moment focus shifted across descendants.
+ if (
+ event.relatedTarget instanceof Node &&
+ event.currentTarget.contains(event.relatedTarget)
+ ) {
+ return;
+ }
+ setOpen(false);
+ };
+
return (