@rawdash/connector-google-play-console 0.28.0 → 0.28.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -9
- package/dist/index.d.ts +273 -2
- package/dist/index.js +670 -138
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../connector-shared/src/errors.ts","../../../connector-shared/src/retry.ts","../../../connector-shared/src/version.ts","../../../connector-shared/src/request.ts","../../../connector-shared/src/rate-limit.ts","../../../connector-shared/src/map-concurrent.ts","../../../connector-shared/src/sanitize.ts","../../../connector-shared/src/epoch.ts","../../../connector-shared/src/pagination.ts","../../../connector-shared/src/logger.ts","../src/google-play-console.ts","../src/index.ts"],"sourcesContent":["import type { HttpResponse } from './types';\n\nexport type HttpErrorKind =\n | 'transient'\n | 'rate_limit'\n | 'auth'\n | 'upstream_bug'\n | 'client_bug';\n\nexport abstract class HttpClientError extends Error {\n abstract readonly kind: HttpErrorKind;\n readonly response?: HttpResponse;\n\n constructor(message: string, response?: HttpResponse) {\n super(message);\n this.name = new.target.name;\n this.response = response;\n }\n}\n\nexport class TransientError extends HttpClientError {\n readonly kind = 'transient' as const;\n}\n\nexport class RateLimitError extends HttpClientError {\n readonly kind = 'rate_limit' as const;\n readonly retryAfter?: Date;\n\n constructor(message: string, response?: HttpResponse, retryAfter?: Date) {\n super(message, response);\n this.retryAfter = retryAfter;\n }\n}\n\nexport class AuthError extends HttpClientError {\n readonly kind = 'auth' as const;\n}\n\nexport class UpstreamBugError extends HttpClientError {\n readonly kind = 'upstream_bug' as const;\n}\n\nexport class ClientBugError extends HttpClientError {\n readonly kind = 'client_bug' as const;\n}\n\nexport function classifyStatus(status: number): HttpErrorKind {\n if (status === 429) {\n return 'rate_limit';\n }\n if (status === 401 || status === 403) {\n return 'auth';\n }\n if (status === 408) {\n return 'transient';\n }\n if (status >= 500) {\n return 'upstream_bug';\n }\n if (status >= 400) {\n return 'client_bug';\n }\n return 'client_bug';\n}\n\nexport function errorForStatus(\n message: string,\n response: HttpResponse,\n retryAfter?: Date,\n): HttpClientError {\n const kind = classifyStatus(response.status);\n switch (kind) {\n case 'rate_limit':\n return new RateLimitError(message, response, retryAfter);\n case 'auth':\n return new AuthError(message, response);\n case 'transient':\n return new TransientError(message, response);\n case 'upstream_bug':\n return new UpstreamBugError(message, response);\n case 'client_bug':\n return new ClientBugError(message, response);\n }\n}\n","import { HttpClientError, RateLimitError, TransientError } from './errors';\n\nexport interface RetryPolicy {\n maxAttempts?: number;\n initialDelayMs?: number;\n maxDelayMs?: number;\n retryOn?: (status: number | null, err?: Error) => boolean;\n}\n\nexport const defaultRetryOn = (status: number | null, err?: Error): boolean => {\n if (err instanceof RateLimitError) {\n return true;\n }\n if (err instanceof TransientError) {\n return true;\n }\n if (status === null) {\n return err instanceof Error && !(err instanceof HttpClientError);\n }\n if (status === 408 || status === 429) {\n return true;\n }\n if (status >= 500) {\n return true;\n }\n return false;\n};\n\nexport function backoffDelayMs(\n attempt: number,\n policy: Required<Pick<RetryPolicy, 'initialDelayMs' | 'maxDelayMs'>>,\n): number {\n const base = policy.initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, policy.maxDelayMs);\n}\n\nexport function parseRetryAfter(\n headerValue: string | null,\n now: Date = new Date(),\n): Date | undefined {\n if (!headerValue) {\n return undefined;\n }\n const trimmed = headerValue.trim();\n if (/^\\d+$/.test(trimmed)) {\n return new Date(now.getTime() + Number(trimmed) * 1000);\n }\n const parsed = Date.parse(trimmed);\n if (Number.isNaN(parsed)) {\n return undefined;\n }\n return new Date(parsed);\n}\n\nexport function sleep(ms: number, signal?: AbortSignal): Promise<void> {\n if (signal?.aborted) {\n return Promise.reject(signal.reason ?? new Error('Aborted'));\n }\n return new Promise<void>((resolve, reject) => {\n const onAbort = () => {\n clearTimeout(timer);\n reject(signal!.reason ?? new Error('Aborted'));\n };\n const timer = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n","export const HTTP_CLIENT_VERSION = '0.0.0';\n\nexport const DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n\nexport function connectorUserAgent(connectorId: string): string {\n return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n}\n","import {\n AuthError,\n ClientBugError,\n HttpClientError,\n RateLimitError,\n TransientError,\n UpstreamBugError,\n errorForStatus,\n} from './errors';\nimport { defaultRetryOn, parseRetryAfter, sleep } from './retry';\nimport type { FetchLike, HttpMethod, HttpRequest, HttpResponse } from './types';\nimport { DEFAULT_USER_AGENT } from './version';\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_MAX_ATTEMPTS = 3;\nconst DEFAULT_INITIAL_DELAY_MS = 1000;\nconst DEFAULT_MAX_DELAY_MS = 60_000;\nconst OBSERVER_TIMEOUT_MS = 250;\n\nexport interface RequestObservation {\n url: string;\n method: HttpMethod;\n status: number;\n resource: string;\n requestId: string;\n body: unknown;\n}\n\nexport type RequestObserver = (\n event: RequestObservation,\n) => void | Promise<void>;\n\nexport interface RequestOptions {\n fetch?: FetchLike;\n observer?: RequestObserver;\n resource: string;\n requestId?: string;\n}\n\nasync function notifyObserver(\n observer: RequestObserver,\n event: RequestObservation,\n): Promise<void> {\n let result: void | Promise<void>;\n try {\n result = observer(event);\n } catch (err) {\n console.warn('[connector-shared] request observer threw:', err);\n return;\n }\n if (!(result instanceof Promise)) {\n return;\n }\n const guarded = result.catch((err) => {\n console.warn('[connector-shared] request observer rejected:', err);\n });\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<void>((resolve) => {\n timer = setTimeout(resolve, OBSERVER_TIMEOUT_MS);\n });\n try {\n await Promise.race([guarded, timeout]);\n } finally {\n if (timer) {\n clearTimeout(timer);\n }\n }\n}\n\nfunction newRequestId(): string {\n const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;\n if (c?.randomUUID) {\n return c.randomUUID();\n }\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\nfunction mergeHeaders(\n defaults: Record<string, string>,\n overrides: Record<string, string> | undefined,\n): Record<string, string> {\n const merged: Record<string, string> = {};\n for (const [k, v] of Object.entries(defaults)) {\n merged[k.toLowerCase()] = v;\n }\n if (overrides) {\n for (const [k, v] of Object.entries(overrides)) {\n merged[k.toLowerCase()] = v;\n }\n }\n return merged;\n}\n\nfunction linkTimeoutSignal(\n parent: AbortSignal | undefined,\n timeoutMs: number,\n): { signal: AbortSignal; cancel: () => void } {\n const controller = new AbortController();\n const onParentAbort = () => {\n controller.abort(parent?.reason);\n };\n if (parent) {\n if (parent.aborted) {\n controller.abort(parent.reason);\n } else {\n parent.addEventListener('abort', onParentAbort, { once: true });\n }\n }\n const timer = setTimeout(() => {\n controller.abort(new Error(`Request timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n return {\n signal: controller.signal,\n cancel: () => {\n clearTimeout(timer);\n if (parent) {\n parent.removeEventListener('abort', onParentAbort);\n }\n },\n };\n}\n\nasync function readBody(res: Response, parseJson: boolean): Promise<unknown> {\n if (res.status === 204 || res.status === 205) {\n return null;\n }\n const contentType = res.headers.get('content-type') ?? '';\n if (parseJson && contentType.includes('application/json')) {\n const text = await res.text();\n if (text.length === 0) {\n return null;\n }\n return JSON.parse(text);\n }\n return res.text();\n}\n\nexport async function request<T = unknown>(\n req: HttpRequest,\n options: RequestOptions,\n): Promise<HttpResponse<T>> {\n const fetchImpl: FetchLike = options.fetch ?? (globalThis.fetch as FetchLike);\n const retry = req.retry ?? {};\n const maxAttempts = retry.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const initialDelayMs = retry.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;\n const maxDelayMs = retry.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;\n const retryOn = retry.retryOn ?? defaultRetryOn;\n const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const parseJson = req.parseJson ?? true;\n\n const headers = mergeHeaders(\n {\n 'User-Agent': DEFAULT_USER_AGENT,\n Accept: 'application/json',\n },\n req.headers,\n );\n\n let lastErr: Error | undefined;\n\n for (let attempt = 0; attempt < maxAttempts; attempt++) {\n req.signal?.throwIfAborted();\n\n const { signal, cancel } = linkTimeoutSignal(req.signal, timeoutMs);\n let res: Response;\n try {\n res = await fetchImpl(req.url, {\n method: req.method ?? 'GET',\n headers,\n body: req.body as RequestInit['body'],\n signal,\n });\n } catch (err) {\n cancel();\n if (req.signal?.aborted) {\n throw req.signal.reason ?? err;\n }\n const error = err instanceof Error ? err : new Error(String(err));\n lastErr = error;\n if (attempt < maxAttempts - 1 && retryOn(null, error)) {\n const delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n await sleep(delay, req.signal);\n continue;\n }\n throw new TransientError(error.message);\n }\n cancel();\n\n const body = await readBody(res, parseJson);\n const httpResponse: HttpResponse<T> = {\n status: res.status,\n headers: res.headers,\n body: body as T,\n };\n if (req.rateLimit) {\n const state = req.rateLimit.parse(res.headers);\n if (state) {\n httpResponse.rateLimitState = state;\n }\n }\n\n if (options.observer) {\n await notifyObserver(options.observer, {\n url: req.url,\n method: req.method ?? 'GET',\n status: res.status,\n resource: options.resource,\n requestId: options.requestId ?? newRequestId(),\n body,\n });\n }\n\n if (res.ok) {\n return httpResponse;\n }\n\n const retryAfter = parseRetryAfter(res.headers.get('retry-after'));\n const message = `HTTP ${res.status} ${res.statusText} for ${req.method ?? 'GET'} ${req.url}`;\n const err = errorForStatus(message, httpResponse, retryAfter);\n\n if (\n attempt < maxAttempts - 1 &&\n retryOn(res.status, err) &&\n !(err instanceof AuthError) &&\n !(err instanceof ClientBugError)\n ) {\n lastErr = err;\n let delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n if (err instanceof RateLimitError && retryAfter) {\n const wait = retryAfter.getTime() - Date.now();\n if (wait > 0) {\n delay = Math.min(wait, maxDelayMs);\n }\n }\n await sleep(delay, req.signal);\n continue;\n }\n\n throw err;\n }\n\n throw lastErr ?? new UpstreamBugError('Exhausted retry attempts');\n}\n\nfunction computeDelay(\n attempt: number,\n initialDelayMs: number,\n maxDelayMs: number,\n): number {\n const base = initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, maxDelayMs);\n}\n\nexport { HttpClientError };\n","export interface RateLimitState {\n remaining: number;\n resetAt: Date;\n}\n\nexport interface RateLimitPolicy {\n parse(headers: Headers): RateLimitState | null;\n}\n\nexport interface StandardRateLimitPolicyConfig {\n remainingHeader: string;\n resetHeader: string;\n resetUnit: 's' | 'ms';\n resetFallbackMs?: number;\n}\n\nexport function standardRateLimitPolicy(\n config: StandardRateLimitPolicyConfig,\n): RateLimitPolicy {\n const { remainingHeader, resetHeader, resetUnit, resetFallbackMs } = config;\n const multiplier = resetUnit === 's' ? 1000 : 1;\n return {\n parse(h) {\n const remainingRaw = h.get(remainingHeader);\n if (remainingRaw === null || remainingRaw.trim() === '') {\n return null;\n }\n const remaining = Number(remainingRaw);\n if (!Number.isFinite(remaining)) {\n return null;\n }\n const resetRaw = h.get(resetHeader);\n if (resetRaw === null) {\n if (resetFallbackMs === undefined) {\n return null;\n }\n return {\n remaining,\n resetAt: new Date(Date.now() + resetFallbackMs),\n };\n }\n if (resetRaw.trim() === '') {\n return null;\n }\n const reset = Number(resetRaw);\n if (!Number.isFinite(reset) || reset < 0) {\n return null;\n }\n const resetMs = reset * multiplier;\n if (!Number.isFinite(resetMs)) {\n return null;\n }\n return { remaining, resetAt: new Date(resetMs) };\n },\n };\n}\n","export async function mapWithConcurrency<T, R>(\n items: readonly T[],\n concurrency: number,\n fn: (item: T, index: number) => Promise<R>,\n): Promise<R[]> {\n const results = new Array<R>(items.length);\n if (items.length === 0) {\n return results;\n }\n const normalized = Number.isFinite(concurrency) ? Math.floor(concurrency) : 1;\n const limit = Math.max(1, Math.min(normalized, items.length));\n let next = 0;\n let failed = false;\n\n async function worker(): Promise<void> {\n while (!failed) {\n const i = next++;\n if (i >= items.length) {\n return;\n }\n try {\n results[i] = await fn(items[i]!, i);\n } catch (err) {\n failed = true;\n throw err;\n }\n }\n }\n\n const workers: Promise<void>[] = [];\n for (let w = 0; w < limit; w++) {\n workers.push(worker());\n }\n await Promise.all(workers);\n return results;\n}\n","export interface SanitizeAllowedUrlOptions {\n url: string | null;\n host: string;\n pathname: string;\n protocol?: 'https:' | 'http:';\n}\n\nexport function sanitizeAllowedUrl(\n options: SanitizeAllowedUrlOptions,\n): string | null {\n const { url, host, pathname, protocol = 'https:' } = options;\n if (url === null) {\n return null;\n }\n try {\n const u = new URL(url);\n if (u.protocol !== protocol || u.host !== host || u.pathname !== pathname) {\n return null;\n }\n return u.toString();\n } catch {\n return null;\n }\n}\n","export type EpochUnit = 'ms' | 's' | 'iso';\n\nexport function parseEpoch(\n value: number | string | null | undefined,\n unit: EpochUnit,\n): number | null {\n if (value === null || value === undefined) {\n return null;\n }\n if (unit === 'iso') {\n if (typeof value !== 'string') {\n return null;\n }\n const ms = new Date(value).getTime();\n return Number.isFinite(ms) ? ms : null;\n }\n if (typeof value === 'string' && value.trim() === '') {\n return null;\n }\n const n = typeof value === 'number' ? value : Number(value);\n if (!Number.isFinite(n)) {\n return null;\n }\n const result = unit === 's' ? n * 1000 : n;\n return Number.isFinite(result) ? result : null;\n}\n","import { request } from './request';\nimport type { HttpRequest } from './types';\n\nexport function parseLinkHeader(header: string | null): Record<string, string> {\n if (!header) {\n return {};\n }\n const result: Record<string, string> = {};\n for (const part of header.split(',')) {\n const match = part.match(/<([^>]+)>\\s*;\\s*rel=\"([^\"]+)\"/);\n if (match) {\n result[match[2]!] = match[1]!;\n }\n }\n return result;\n}\n\nexport async function* paginateLink<T>(\n initial: HttpRequest,\n parse: (body: unknown) => T[],\n options: { resource: string },\n): AsyncIterable<T> {\n let next: string | null = initial.url;\n while (next) {\n const res: Awaited<ReturnType<typeof request>> = await request(\n {\n ...initial,\n url: next,\n },\n { resource: options.resource },\n );\n for (const item of parse(res.body)) {\n yield item;\n }\n const links = parseLinkHeader(res.headers.get('link'));\n next = links['next'] ?? null;\n }\n}\n\nexport async function* paginateCursor<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; nextCursor: string | null },\n buildNext: (req: HttpRequest, cursor: string) => HttpRequest,\n options: { resource: string },\n): AsyncIterable<T> {\n let req: HttpRequest = initial;\n while (true) {\n const res = await request(req, { resource: options.resource });\n const { items, nextCursor } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!nextCursor) {\n return;\n }\n req = buildNext(req, nextCursor);\n }\n}\n\nexport async function* paginatePage<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; hasMore: boolean },\n buildPage: (req: HttpRequest, page: number) => HttpRequest,\n options: { resource: string },\n): AsyncIterable<T> {\n let page = 1;\n while (true) {\n const req = page === 1 ? initial : buildPage(initial, page);\n const res = await request(req, { resource: options.resource });\n const { items, hasMore } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!hasMore || items.length === 0) {\n return;\n }\n page++;\n }\n}\n","export type LogFields = Record<string, unknown>;\n\nexport interface ConnectorLogger {\n info(event: string, fields?: LogFields): void;\n warn(event: string, fields?: LogFields): void;\n}\n\nexport interface ConnectorLoggerOptions {\n scope: string;\n}\n\nconst MAX_VALUE_LEN = 120;\n\nfunction truncate(s: string, max = MAX_VALUE_LEN): string {\n if (s.length <= max) {\n return s;\n }\n return `${s.slice(0, max - 1)}…`;\n}\n\nfunction formatValue(value: unknown): string {\n if (value === null) {\n return 'null';\n }\n if (value === undefined) {\n return '';\n }\n if (typeof value === 'number' || typeof value === 'boolean') {\n return String(value);\n }\n if (typeof value === 'string') {\n const t = truncate(value);\n if (/[\\s\"=]/.test(t)) {\n return JSON.stringify(t);\n }\n return t;\n }\n if (typeof value === 'bigint') {\n return value.toString();\n }\n let json: string | undefined;\n try {\n json = JSON.stringify(value);\n } catch {\n json = undefined;\n }\n return truncate(json ?? String(value));\n}\n\nexport function formatLogFields(fields?: LogFields): string {\n if (!fields) {\n return '';\n }\n const parts: string[] = [];\n for (const [k, v] of Object.entries(fields)) {\n if (v === undefined) {\n continue;\n }\n parts.push(`${k}=${formatValue(v)}`);\n }\n return parts.length > 0 ? ` ${parts.join(' ')}` : '';\n}\n\nexport function formatLogLine(\n scope: string,\n event: string,\n fields?: LogFields,\n): string {\n return `[${scope}] ${event}${formatLogFields(fields)}`;\n}\n\nexport function createDefaultConnectorLogger(\n opts: ConnectorLoggerOptions,\n): ConnectorLogger {\n return {\n info(event, fields) {\n console.info(formatLogLine(opts.scope, event, fields));\n },\n warn(event, fields) {\n console.warn(formatLogLine(opts.scope, event, fields));\n },\n };\n}\n\nconst NOOP_LOGGER: ConnectorLogger = {\n info() {},\n warn() {},\n};\n\nexport function noopConnectorLogger(): ConnectorLogger {\n return NOOP_LOGGER;\n}\n","import { connectorUserAgent } from '@rawdash/connector-shared';\nimport {\n BaseConnector,\n type ConnectorContext,\n type ConnectorDoc,\n type CredentialsSchema,\n type Entity,\n type StorageHandle,\n type SyncOptions,\n type SyncResult,\n defineConfigFields,\n defineConnectorDoc,\n defineResources,\n schemasFromResources,\n selectActivePhases,\n} from '@rawdash/core';\nimport { z } from 'zod';\n\nexport const configFields = defineConfigFields(\n z.object({\n packageName: z\n .string()\n .trim()\n .regex(/^[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)+$/, {\n message:\n 'packageName must be a reverse-DNS application id (e.g. com.example.app).',\n })\n .meta({\n label: 'Package name',\n description:\n 'Reverse-DNS application id of the Android app (e.g. com.example.app). Visible in the Play Console URL and on Google Play under \"About\".',\n placeholder: 'com.example.app',\n }),\n serviceAccountJson: z.object({ $secret: z.string().trim().min(1) }).meta({\n label: 'Service Account JSON',\n description:\n 'Contents of the JSON key file for a Google service account that has been granted access to your Play Console developer account with at least the \"View app information and download bulk reports\" permission. Create one at Google Cloud -> IAM & Admin -> Service Accounts.',\n secret: true,\n }),\n lookbackDays: z.number().int().positive().optional().meta({\n label: 'Lookback days (full sync)',\n description:\n 'How many calendar days to fetch on a full sync. Defaults to 30. The Play Developer Reporting API exposes daily metrics with a typical 2-3 day reporting lag.',\n placeholder: '30',\n }),\n reviewLimit: z.number().int().positive().max(2000).optional().meta({\n label: 'Review sample size',\n description:\n 'How many of the most-recent user reviews to emit as gplay_app_ratings samples. Defaults to 200. Reviews are fetched then ranked newest-first before this cap is applied. The Android Publisher reviews API only surfaces reviews from roughly the past week, so this is a rolling sample, not a full history.',\n placeholder: '200',\n }),\n }),\n);\n\nexport const doc: ConnectorDoc = defineConnectorDoc({\n displayName: 'Google Play Console',\n category: 'engineering',\n brandColor: '#34A853',\n tagline:\n 'Sync daily Android app vitals from the Play Developer Reporting API (crash rate, ANR rate, error counts) plus user review ratings from the Android Publisher API.',\n vendor: {\n name: 'Google Play Console',\n domain: 'play.google.com',\n apiDocs: 'https://developers.google.com/play/developer/reporting',\n website: 'https://play.google.com/console/',\n },\n auth: {\n summary:\n 'Authenticate against the Play Developer Reporting API and the Android Publisher API with a Google service account JSON key. The service account must be linked to your Play Console developer account.',\n setup: [\n 'In Google Cloud, create a service account at IAM & Admin -> Service Accounts and download a JSON key.',\n 'Enable both the \"Google Play Developer Reporting API\" and the \"Google Play Android Developer API\" on the Cloud project.',\n 'In Google Play Console open Setup -> API access, link the same Cloud project, then invite the service account email and grant it at least the \"View app information and download bulk reports\" permission for the app you want to sync.',\n 'Store the service account JSON as a secret and reference it as serviceAccountJson: secret(\"GPLAY_SA_JSON\").',\n 'Set packageName to the reverse-DNS application id of the app (e.g. com.example.app).',\n ],\n },\n rateLimit:\n 'The Play Developer Reporting API enforces a per-project quota (default 60 requests per minute); 429 responses are retried with exponential backoff.',\n limitations: [\n 'Daily vitals (crash rate, ANR rate, error counts) have a 2-3 day reporting lag on the Play Developer Reporting API; incremental syncs refetch the trailing 3 days. Metric days are reported on the America/Los_Angeles calendar, the only timezone the API supports for daily aggregation.',\n 'gplay_app_ratings is a rolling sample of recent reviews from the Android Publisher reviews API (default 200, configurable via reviewLimit). Each sample carries one review with its star rating (1-5) as the value; this is not the lifetime average shown on the Play Store, and the reviews API only surfaces reviews from roughly the past week.',\n 'The apps entity carries only the configured package name; the Play Store listing title is available solely through an Android Publisher edit, which this connector does not create.',\n 'Install counts and earnings are not exposed through the Reporting API - Google delivers them only as monthly CSV reports in a private Cloud Storage bucket. Those metrics are out of scope for this connector and will land in a follow-up.',\n ],\n});\n\nexport interface GooglePlayConsoleSettings {\n packageName: string;\n lookbackDays?: number;\n reviewLimit?: number;\n}\n\nconst gplayCredentials = {\n serviceAccountJson: {\n description: 'Google service account JSON key (base64 or raw JSON)',\n auth: 'required' as const,\n },\n} satisfies CredentialsSchema;\n\ntype GplayCredentials = typeof gplayCredentials;\n\nconst PHASE_ORDER = [\n 'apps',\n 'crash_rate',\n 'anr_rate',\n 'errors',\n 'reviews',\n] as const;\n\ntype GplayPhase = (typeof PHASE_ORDER)[number];\n\ntype MetricPhase = 'crash_rate' | 'anr_rate' | 'errors';\n\ninterface GplayDateRange {\n startDate: string;\n endDate: string;\n}\n\ninterface GplaySyncCursor {\n phase: GplayPhase;\n dateRange: GplayDateRange;\n}\n\nconst GPLAY_DATE_RE = /^\\d{4}-\\d{2}-\\d{2}$/;\n\nfunction isGplayDateString(value: unknown): value is string {\n return typeof value === 'string' && GPLAY_DATE_RE.test(value);\n}\n\nfunction isGplayDateRange(value: unknown): value is GplayDateRange {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n const v = value as { startDate?: unknown; endDate?: unknown };\n return isGplayDateString(v.startDate) && isGplayDateString(v.endDate);\n}\n\nfunction isGplaySyncCursor(value: unknown): value is GplaySyncCursor {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n const v = value as { phase?: unknown; dateRange?: unknown };\n if (typeof v.phase !== 'string') {\n return false;\n }\n if (!(PHASE_ORDER as readonly string[]).includes(v.phase)) {\n return false;\n }\n return isGplayDateRange(v.dateRange);\n}\n\ninterface MetricPhaseConfig {\n metricSet: string;\n metrics: string[];\n metricName: string;\n primaryMetric: string;\n responseTag: string;\n}\n\nconst METRIC_PHASE_CONFIGS: Record<MetricPhase, MetricPhaseConfig> = {\n crash_rate: {\n metricSet: 'crashRateMetricSet',\n metrics: ['crashRate', 'distinctUsers'],\n metricName: 'gplay_crash_rate_by_day',\n primaryMetric: 'crashRate',\n responseTag: 'crash_rate',\n },\n anr_rate: {\n metricSet: 'anrRateMetricSet',\n metrics: ['anrRate', 'distinctUsers'],\n metricName: 'gplay_anr_rate_by_day',\n primaryMetric: 'anrRate',\n responseTag: 'anr_rate',\n },\n errors: {\n metricSet: 'errorCountMetricSet',\n metrics: ['errorReportCount', 'distinctUsers'],\n metricName: 'gplay_error_count_by_day',\n primaryMetric: 'errorReportCount',\n responseTag: 'errors',\n },\n};\n\nconst GPLAY_APP_RATINGS_METRIC = 'gplay_app_ratings';\n\nconst RESOURCE_TO_PHASE: Record<string, GplayPhase> = {\n apps: 'apps',\n gplay_crash_rate_by_day: 'crash_rate',\n gplay_anr_rate_by_day: 'anr_rate',\n gplay_error_count_by_day: 'errors',\n [GPLAY_APP_RATINGS_METRIC]: 'reviews',\n};\n\nconst SCOPES = [\n 'https://www.googleapis.com/auth/playdeveloperreporting',\n 'https://www.googleapis.com/auth/androidpublisher',\n].join(' ');\n\nconst REPORTING_BASE = 'https://playdeveloperreporting.googleapis.com';\nconst PUBLISHER_BASE = 'https://androidpublisher.googleapis.com';\n\nconst DAILY_TIME_ZONE = 'America/Los_Angeles';\n\nconst DEFAULT_REVIEW_LIMIT = 200;\nconst REVIEWS_PAGE_SIZE = 100;\nconst MAX_REVIEW_PAGES = 50;\n\nexport interface GplayTimelineDate {\n year?: number;\n month?: number;\n day?: number;\n}\n\nexport interface GplayTimelinePoint {\n startTime?: { year?: number; month?: number; day?: number };\n}\n\nexport interface GplayMetricValue {\n metric?: string;\n decimalValue?: { value?: string };\n decimalValueConfidenceInterval?: unknown;\n}\n\nexport interface GplayMetricRow {\n startTime?: { year?: number; month?: number; day?: number };\n metrics?: GplayMetricValue[];\n}\n\ninterface GplayMetricResponse {\n rows?: GplayMetricRow[];\n nextPageToken?: string;\n}\n\nexport interface GplayTimestamp {\n seconds?: string;\n nanos?: number;\n}\n\nexport interface GplayUserComment {\n text?: string;\n lastModified?: GplayTimestamp;\n starRating?: number;\n reviewerLanguage?: string;\n device?: string;\n androidOsVersion?: number;\n appVersionCode?: number;\n appVersionName?: string;\n}\n\nexport interface GplayReview {\n reviewId?: string;\n authorName?: string;\n comments?: Array<{ userComment?: GplayUserComment }>;\n}\n\ninterface GplayReviewsResponse {\n reviews?: GplayReview[];\n tokenPagination?: { nextPageToken?: string };\n}\n\ninterface ServiceAccountKey {\n client_email: string;\n private_key: string;\n token_uri?: string;\n}\n\ninterface TokenResponse {\n access_token: string;\n expires_in?: number;\n}\n\nfunction base64urlFromBytes(bytes: Uint8Array): string {\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]!);\n }\n return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n}\n\nfunction base64urlFromString(str: string): string {\n return base64urlFromBytes(new TextEncoder().encode(str));\n}\n\nasync function signRS256JWT(\n payload: Record<string, unknown>,\n privateKeyPem: string,\n): Promise<string> {\n const header = { alg: 'RS256', typ: 'JWT' };\n const headerB64 = base64urlFromString(JSON.stringify(header));\n const payloadB64 = base64urlFromString(JSON.stringify(payload));\n const signingInput = `${headerB64}.${payloadB64}`;\n\n const pemContent = privateKeyPem\n .replace(/-----BEGIN PRIVATE KEY-----/g, '')\n .replace(/-----END PRIVATE KEY-----/g, '')\n .replace(/\\s/g, '');\n const der = Uint8Array.from(atob(pemContent), (c) => c.charCodeAt(0));\n\n const key = await globalThis.crypto.subtle.importKey(\n 'pkcs8',\n der,\n { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },\n false,\n ['sign'],\n );\n\n const signature = await globalThis.crypto.subtle.sign(\n 'RSASSA-PKCS1-v1_5',\n key,\n new TextEncoder().encode(signingInput),\n );\n\n return `${signingInput}.${base64urlFromBytes(new Uint8Array(signature))}`;\n}\n\nfunction parseServiceAccountJson(value: string): ServiceAccountKey {\n const trimmed = value.trim();\n if (trimmed.startsWith('{')) {\n return JSON.parse(trimmed) as ServiceAccountKey;\n }\n const binary = atob(trimmed);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n const decoded = new TextDecoder().decode(bytes);\n return JSON.parse(decoded) as ServiceAccountKey;\n}\n\nasync function buildServiceAccountJwt(\n serviceAccountJson: string,\n): Promise<{ url: string; body: string }> {\n const sa = parseServiceAccountJson(serviceAccountJson);\n const now = Math.floor(Date.now() / 1000);\n const jwt = await signRS256JWT(\n {\n iss: sa.client_email,\n scope: SCOPES,\n aud: sa.token_uri ?? 'https://oauth2.googleapis.com/token',\n exp: now + 3600,\n iat: now,\n },\n sa.private_key,\n );\n\n const body = new URLSearchParams({\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n assertion: jwt,\n }).toString();\n\n return {\n url: sa.token_uri ?? 'https://oauth2.googleapis.com/token',\n body,\n };\n}\n\nconst gplayDateFormatter = new Intl.DateTimeFormat('en-CA', {\n timeZone: DAILY_TIME_ZONE,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n});\n\nfunction toGplayDate(date: Date): string {\n return gplayDateFormatter.format(date);\n}\n\nfunction gplayDateToMs(gplayDate: string): number {\n const y = gplayDate.slice(0, 4);\n const m = gplayDate.slice(5, 7);\n const d = gplayDate.slice(8, 10);\n return Date.UTC(Number(y), Number(m) - 1, Number(d));\n}\n\nfunction partsToGplayDate(parts: {\n year?: number;\n month?: number;\n day?: number;\n}): string | null {\n const { year, month, day } = parts;\n if (\n typeof year !== 'number' ||\n typeof month !== 'number' ||\n typeof day !== 'number' ||\n !Number.isInteger(year) ||\n !Number.isInteger(month) ||\n !Number.isInteger(day) ||\n year < 1970 ||\n year > 2999 ||\n month < 1 ||\n month > 12 ||\n day < 1 ||\n day > 31\n ) {\n return null;\n }\n const y = String(year).padStart(4, '0');\n const m = String(month).padStart(2, '0');\n const d = String(day).padStart(2, '0');\n return `${y}-${m}-${d}`;\n}\n\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\nconst INCREMENTAL_LOOKBACK_DAYS = 3;\n\nfunction getDateRange(\n options: SyncOptions,\n lookbackDays: number,\n): GplayDateRange {\n const now = Date.now();\n const endDate = toGplayDate(new Date(now));\n if (options.mode === 'latest') {\n const startMs = now - (INCREMENTAL_LOOKBACK_DAYS - 1) * MS_PER_DAY;\n return { startDate: toGplayDate(new Date(startMs)), endDate };\n }\n if (options.since) {\n const sinceMs = new Date(options.since).getTime();\n if (Number.isFinite(sinceMs)) {\n const days = Math.max(1, Math.ceil((now - sinceMs) / MS_PER_DAY));\n const cappedDays = Math.min(days, lookbackDays);\n const startMs = now - (cappedDays - 1) * MS_PER_DAY;\n return { startDate: toGplayDate(new Date(startMs)), endDate };\n }\n }\n const startMs = now - (lookbackDays - 1) * MS_PER_DAY;\n return { startDate: toGplayDate(new Date(startMs)), endDate };\n}\n\nexport function rowToMetricSample(\n row: GplayMetricRow,\n metricsToCollect: string[],\n metricName: string,\n primaryMetric: string,\n packageName: string,\n): {\n name: string;\n ts: number;\n value: number;\n attributes: Record<string, string | number>;\n} | null {\n const dateStr = partsToGplayDate(row.startTime ?? {});\n if (!dateStr) {\n return null;\n }\n const attributes: Record<string, string | number> = {\n date: dateStr,\n package_name: packageName,\n };\n\n for (const m of metricsToCollect) {\n attributes[m] = 0;\n }\n for (const m of row.metrics ?? []) {\n if (!m.metric) {\n continue;\n }\n const raw = m.decimalValue?.value;\n const parsed = typeof raw === 'string' ? Number(raw) : NaN;\n attributes[m.metric] = Number.isFinite(parsed) ? parsed : 0;\n }\n\n const primary = attributes[primaryMetric];\n const value = typeof primary === 'number' ? primary : 0;\n\n return {\n name: metricName,\n ts: gplayDateToMs(dateStr),\n value,\n attributes,\n };\n}\n\nfunction timestampToMs(ts: GplayTimestamp | undefined): number | null {\n if (!ts || typeof ts.seconds !== 'string') {\n return null;\n }\n const seconds = Number(ts.seconds);\n if (!Number.isFinite(seconds)) {\n return null;\n }\n const nanos = typeof ts.nanos === 'number' ? ts.nanos : 0;\n return Math.round(seconds * 1000 + nanos / 1e6);\n}\n\nexport function reviewToRatingSample(\n review: GplayReview,\n packageName: string,\n): {\n name: string;\n ts: number;\n value: number;\n attributes: Record<string, string | number>;\n} | null {\n const userComment = review.comments?.find((c) => c.userComment)?.userComment;\n if (!userComment) {\n return null;\n }\n const rating = userComment.starRating;\n if (typeof rating !== 'number' || rating < 1 || rating > 5) {\n return null;\n }\n const ts = timestampToMs(userComment.lastModified);\n if (ts === null) {\n return null;\n }\n\n const attributes: Record<string, string | number> = {\n package_name: packageName,\n review_id: review.reviewId ?? '',\n };\n if (userComment.reviewerLanguage) {\n attributes['reviewer_language'] = userComment.reviewerLanguage;\n }\n if (userComment.device) {\n attributes['device'] = userComment.device;\n }\n if (userComment.appVersionName) {\n attributes['app_version_name'] = userComment.appVersionName;\n }\n if (typeof userComment.androidOsVersion === 'number') {\n attributes['android_os_version'] = userComment.androidOsVersion;\n }\n\n return {\n name: GPLAY_APP_RATINGS_METRIC,\n ts,\n value: rating,\n attributes,\n };\n}\n\nconst dateOnlyTimeline = z.object({\n startTime: z.object({\n year: z.number().int(),\n month: z.number().int(),\n day: z.number().int(),\n }),\n});\n\nconst metricEntry = z.object({\n metric: z.string(),\n decimalValue: z\n .object({\n value: z.string(),\n })\n .optional(),\n});\n\nfunction metricSetSchema() {\n return z.object({\n rows: z\n .array(\n dateOnlyTimeline.extend({\n metrics: z.array(metricEntry).optional(),\n }),\n )\n .optional(),\n nextPageToken: z.string().optional(),\n });\n}\n\nconst reviewsResponseSchema = z.object({\n reviews: z\n .array(\n z.object({\n reviewId: z.string().optional(),\n authorName: z.string().optional(),\n comments: z\n .array(\n z.object({\n userComment: z\n .object({\n text: z.string().optional(),\n lastModified: z\n .object({\n seconds: z.string().optional(),\n nanos: z.number().optional(),\n })\n .optional(),\n starRating: z.number().int().optional(),\n reviewerLanguage: z.string().optional(),\n device: z.string().optional(),\n androidOsVersion: z.number().int().optional(),\n appVersionCode: z.number().int().optional(),\n appVersionName: z.string().optional(),\n })\n .optional(),\n }),\n )\n .optional(),\n }),\n )\n .optional(),\n tokenPagination: z\n .object({ nextPageToken: z.string().optional() })\n .optional(),\n});\n\nexport const googlePlayConsoleResources = defineResources({\n apps: {\n shape: 'entity',\n filterable: [],\n description:\n 'Android app the connector is syncing. One entity per configured packageName, derived from the connector config; the Play Store listing title is only reachable through an Android Publisher edit and is not fetched.',\n fields: [\n {\n name: 'package_name',\n description: 'Reverse-DNS application id (e.g. com.example.app).',\n },\n ],\n },\n gplay_crash_rate_by_day: {\n shape: 'metric',\n description:\n 'Daily crash rate reported by the Play Developer Reporting API. Primary value is the crashRate metric (fraction of distinct users that experienced a crash).',\n unit: 'crashRate',\n granularity: 'day',\n endpoint: 'POST /v1beta1/apps/{packageName}/crashRateMetricSet:query',\n dimensions: [\n {\n name: 'date',\n description:\n 'Calendar day of the metric sample (America/Los_Angeles, the only timezone the Reporting API supports for daily aggregation).',\n },\n {\n name: 'package_name',\n description:\n 'Reverse-DNS application id this sample is reported against.',\n },\n ],\n responses: { crash_rate: metricSetSchema() },\n },\n gplay_anr_rate_by_day: {\n shape: 'metric',\n description:\n 'Daily ANR (Application Not Responding) rate. Primary value is the anrRate metric (fraction of distinct users that experienced an ANR).',\n unit: 'anrRate',\n granularity: 'day',\n endpoint: 'POST /v1beta1/apps/{packageName}/anrRateMetricSet:query',\n dimensions: [\n {\n name: 'date',\n description:\n 'Calendar day of the metric sample (America/Los_Angeles, the only timezone the Reporting API supports for daily aggregation).',\n },\n {\n name: 'package_name',\n description:\n 'Reverse-DNS application id this sample is reported against.',\n },\n ],\n responses: { anr_rate: metricSetSchema() },\n },\n gplay_error_count_by_day: {\n shape: 'metric',\n description:\n 'Daily count of error reports (crashes + ANRs + handled errors) from the Play Developer Reporting API.',\n unit: 'reports',\n granularity: 'day',\n endpoint: 'POST /v1beta1/apps/{packageName}/errorCountMetricSet:query',\n dimensions: [\n {\n name: 'date',\n description:\n 'Calendar day of the metric sample (America/Los_Angeles, the only timezone the Reporting API supports for daily aggregation).',\n },\n {\n name: 'package_name',\n description:\n 'Reverse-DNS application id this sample is reported against.',\n },\n ],\n responses: { errors: metricSetSchema() },\n },\n gplay_app_ratings: {\n shape: 'metric',\n description:\n 'Rolling per-review star ratings sampled from the most-recent user reviews via the Android Publisher reviews API (default 200, configurable via reviewLimit). Each sample carries one review with its star rating (1-5) as the value.',\n unit: 'stars',\n endpoint: 'GET /androidpublisher/v3/applications/{packageName}/reviews',\n notes:\n 'Not the lifetime average shown on the Play Store. The reviews API only returns reviews from roughly the past week, so this is a rolling sample; average over a time window downstream for a smoothed rating.',\n dimensions: [\n {\n name: 'package_name',\n description:\n 'Reverse-DNS application id this review was filed against.',\n },\n { name: 'review_id', description: 'Unique identifier of the review.' },\n {\n name: 'reviewer_language',\n description: 'BCP-47 language code the review was written in.',\n },\n {\n name: 'device',\n description: 'Codename of the device the review was filed from.',\n },\n {\n name: 'app_version_name',\n description: 'App version name the reviewer was running.',\n },\n {\n name: 'android_os_version',\n description: 'Android SDK version the reviewer was running.',\n },\n ],\n responses: { reviews: reviewsResponseSchema },\n },\n});\n\nexport const id = 'google-play-console';\n\nexport class GooglePlayConsoleConnector extends BaseConnector<\n GooglePlayConsoleSettings,\n GplayCredentials\n> {\n static readonly id = id;\n\n static readonly resources = googlePlayConsoleResources;\n\n static readonly schemas = schemasFromResources(googlePlayConsoleResources);\n\n static create(\n input: unknown,\n ctx?: ConnectorContext,\n ): GooglePlayConsoleConnector {\n const parsed = configFields.parse(input);\n return new GooglePlayConsoleConnector(\n {\n packageName: parsed.packageName,\n lookbackDays: parsed.lookbackDays,\n reviewLimit: parsed.reviewLimit,\n },\n {\n serviceAccountJson: parsed.serviceAccountJson,\n },\n ctx,\n );\n }\n\n readonly id = id;\n override readonly credentials = gplayCredentials;\n\n private cachedToken: { token: string; expiresAt: number } | null = null;\n\n private async fetchOAuthToken(\n url: string,\n body: string,\n signal: AbortSignal | undefined,\n ): Promise<{ token: string; expiresAt: number }> {\n const res = await this.post<TokenResponse>(url, {\n resource: 'oauth_token',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body,\n signal,\n });\n const expiresIn = res.body.expires_in ?? 3600;\n return {\n token: res.body.access_token,\n expiresAt: Date.now() + (expiresIn - 60) * 1000,\n };\n }\n\n private async getAccessToken(signal?: AbortSignal): Promise<string> {\n if (this.cachedToken && Date.now() < this.cachedToken.expiresAt) {\n return this.cachedToken.token;\n }\n\n const { serviceAccountJson } = this.creds;\n if (!serviceAccountJson) {\n throw new Error(\n 'Google Play Console connector: serviceAccountJson credential is required',\n );\n }\n\n const { url, body } = await buildServiceAccountJwt(serviceAccountJson);\n this.cachedToken = await this.fetchOAuthToken(url, body, signal);\n return this.cachedToken.token;\n }\n\n private async runMetricQuery(\n accessToken: string,\n cfg: MetricPhaseConfig,\n dateRange: GplayDateRange,\n pageToken: string | undefined,\n signal?: AbortSignal,\n ): Promise<GplayMetricResponse> {\n const url = `${REPORTING_BASE}/v1beta1/apps/${encodeURIComponent(this.settings.packageName)}/${cfg.metricSet}:query`;\n\n const [sy, sm, sd] = dateRange.startDate.split('-').map(Number) as [\n number,\n number,\n number,\n ];\n const [ey, em, ed] = dateRange.endDate.split('-').map(Number) as [\n number,\n number,\n number,\n ];\n\n const body: Record<string, unknown> = {\n metrics: cfg.metrics,\n timelineSpec: {\n aggregationPeriod: 'DAILY',\n startTime: {\n year: sy,\n month: sm,\n day: sd,\n timeZone: { id: DAILY_TIME_ZONE },\n },\n endTime: {\n year: ey,\n month: em,\n day: ed,\n timeZone: { id: DAILY_TIME_ZONE },\n },\n },\n };\n if (pageToken) {\n body['pageToken'] = pageToken;\n }\n\n const res = await this.post<GplayMetricResponse>(url, {\n resource: cfg.responseTag,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json',\n 'User-Agent': connectorUserAgent('google-play-console'),\n },\n body: JSON.stringify(body),\n signal,\n });\n return res.body;\n }\n\n private async syncApps(storage: StorageHandle): Promise<void> {\n const entity: Entity = {\n type: 'apps',\n id: this.settings.packageName,\n attributes: {\n package_name: this.settings.packageName,\n },\n updated_at: Date.now(),\n };\n await storage.entities([entity], { types: ['apps'] });\n }\n\n private async fetchReviews(\n accessToken: string,\n signal?: AbortSignal,\n ): Promise<GplayReview[]> {\n const reviews: GplayReview[] = [];\n const base = `${PUBLISHER_BASE}/androidpublisher/v3/applications/${encodeURIComponent(this.settings.packageName)}/reviews`;\n let token: string | undefined = undefined;\n for (let page = 0; page < MAX_REVIEW_PAGES; page++) {\n signal?.throwIfAborted();\n const params = new URLSearchParams({\n maxResults: String(REVIEWS_PAGE_SIZE),\n });\n if (token) {\n params.set('token', token);\n }\n const res = await this.get<GplayReviewsResponse>(\n `${base}?${params.toString()}`,\n {\n resource: 'reviews',\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'User-Agent': connectorUserAgent('google-play-console'),\n },\n signal,\n },\n );\n reviews.push(...(res.body.reviews ?? []));\n token = res.body.tokenPagination?.nextPageToken;\n if (!token) {\n return reviews;\n }\n }\n this.logger.warn(\n `Stopped paginating Play Console reviews after ${MAX_REVIEW_PAGES} pages; the most-recent reviews are still ranked first but older reviews may be omitted`,\n { packageName: this.settings.packageName },\n );\n return reviews;\n }\n\n private async syncReviews(\n accessToken: string,\n storage: StorageHandle,\n signal?: AbortSignal,\n ): Promise<void> {\n const limit = this.settings.reviewLimit ?? DEFAULT_REVIEW_LIMIT;\n const reviews = await this.fetchReviews(accessToken, signal);\n const samples = reviews\n .map((review) => reviewToRatingSample(review, this.settings.packageName))\n .filter((s): s is NonNullable<typeof s> => s !== null)\n .sort((a, b) => b.ts - a.ts)\n .slice(0, limit);\n await storage.metrics(samples, { names: [GPLAY_APP_RATINGS_METRIC] });\n }\n\n private async drainMetricPhase(\n accessToken: string,\n cfg: MetricPhaseConfig,\n dateRange: GplayDateRange,\n signal?: AbortSignal,\n ): Promise<GplayMetricRow[]> {\n const rows: GplayMetricRow[] = [];\n let pageToken: string | undefined = undefined;\n for (;;) {\n const res: GplayMetricResponse = await this.runMetricQuery(\n accessToken,\n cfg,\n dateRange,\n pageToken,\n signal,\n );\n if (res.rows) {\n rows.push(...res.rows);\n }\n if (!res.nextPageToken) {\n break;\n }\n pageToken = res.nextPageToken;\n }\n return rows;\n }\n\n async sync(\n options: SyncOptions,\n storage: StorageHandle,\n signal?: AbortSignal,\n ): Promise<SyncResult> {\n const lookbackDays = this.settings.lookbackDays ?? 30;\n\n const cursor = isGplaySyncCursor(options.cursor)\n ? options.cursor\n : undefined;\n const dateRange = cursor?.dateRange ?? getDateRange(options, lookbackDays);\n\n let accessToken: string | null = null;\n const getToken = async (sig?: AbortSignal): Promise<string> => {\n if (!accessToken) {\n accessToken = await this.getAccessToken(sig);\n }\n return accessToken;\n };\n\n const phases = selectActivePhases<string, GplayPhase>(\n (resource) => RESOURCE_TO_PHASE[resource] ?? 'apps',\n PHASE_ORDER,\n options.resources ? [...options.resources] : undefined,\n );\n\n const resumeIdx = cursor ? phases.indexOf(cursor.phase) : -1;\n const startIdx = resumeIdx >= 0 ? resumeIdx : 0;\n\n for (let i = startIdx; i < phases.length; i++) {\n const phase = phases[i]!;\n if (signal?.aborted) {\n return { done: false, cursor: { phase, dateRange } };\n }\n\n try {\n if (phase === 'apps') {\n await this.syncApps(storage);\n continue;\n }\n\n if (phase === 'reviews') {\n const token = await getToken(signal);\n await this.syncReviews(token, storage, signal);\n continue;\n }\n\n const cfg = METRIC_PHASE_CONFIGS[phase];\n const token = await getToken(signal);\n const rows = await this.drainMetricPhase(token, cfg, dateRange, signal);\n const samples = rows\n .map((row) =>\n rowToMetricSample(\n row,\n cfg.metrics,\n cfg.metricName,\n cfg.primaryMetric,\n this.settings.packageName,\n ),\n )\n .filter((s): s is NonNullable<typeof s> => s !== null);\n await storage.metrics(samples, { names: [cfg.metricName] });\n } catch (err) {\n if (signal?.aborted) {\n return { done: false, cursor: { phase, dateRange } };\n }\n throw err;\n }\n }\n\n return { done: true };\n }\n}\n","import { GooglePlayConsoleConnector } from './google-play-console';\n\nexport {\n configFields,\n doc,\n GooglePlayConsoleConnector,\n googlePlayConsoleResources as resources,\n id,\n rowToMetricSample,\n} from './google-play-console';\nexport type { GooglePlayConsoleSettings } from './google-play-console';\nexport default GooglePlayConsoleConnector;\n"],"mappings":"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","names":["startMs","token"]}
|
|
1
|
+
{"version":3,"sources":["../../gcp-shared/src/auth.ts","../../gcp-shared/src/config.ts","../../../connector-shared/src/errors.ts","../../../connector-shared/src/retry.ts","../../../connector-shared/src/version.ts","../../../connector-shared/src/request.ts","../../../connector-shared/src/rate-limit.ts","../../../connector-shared/src/map-concurrent.ts","../../../connector-shared/src/sanitize.ts","../../../connector-shared/src/epoch.ts","../../../connector-shared/src/pagination.ts","../../../connector-shared/src/logger.ts","../../gcp-shared/src/bigquery.ts","../../gcp-shared/src/access-token.ts","../../gcp-shared/src/dates.ts","../../../connector-shared/src/errors.ts","../../../connector-shared/src/retry.ts","../../../connector-shared/src/version.ts","../../../connector-shared/src/request.ts","../../../connector-shared/src/rate-limit.ts","../../../connector-shared/src/map-concurrent.ts","../../../connector-shared/src/sanitize.ts","../../../connector-shared/src/epoch.ts","../../../connector-shared/src/pagination.ts","../../../connector-shared/src/logger.ts","../src/google-play-console.ts","../src/installs.ts","../src/index.ts"],"sourcesContent":["import { z } from 'zod';\n\nexport interface ServiceAccountKey {\n client_email: string;\n private_key: string;\n token_uri?: string;\n project_id?: string;\n}\n\nconst serviceAccountKeySchema = z.object({\n client_email: z.string().min(1),\n private_key: z.string().min(1),\n token_uri: z.string().url().optional(),\n project_id: z.string().optional(),\n});\n\nexport type ServiceAccountInput = string | Record<string, unknown>;\n\nexport interface TokenResponse {\n access_token: string;\n expires_in?: number;\n}\n\nexport const tokenResponseSchema = z.object({\n access_token: z.string().min(1),\n expires_in: z.number().int().positive().optional(),\n});\n\nfunction base64urlFromBytes(bytes: Uint8Array): string {\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]!);\n }\n return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n}\n\nfunction base64urlFromString(str: string): string {\n return base64urlFromBytes(new TextEncoder().encode(str));\n}\n\nasync function signRS256JWT(\n payload: Record<string, unknown>,\n privateKeyPem: string,\n): Promise<string> {\n const header = { alg: 'RS256', typ: 'JWT' };\n const headerB64 = base64urlFromString(JSON.stringify(header));\n const payloadB64 = base64urlFromString(JSON.stringify(payload));\n const signingInput = `${headerB64}.${payloadB64}`;\n\n const pemContent = privateKeyPem\n .replace(/-----BEGIN PRIVATE KEY-----/g, '')\n .replace(/-----END PRIVATE KEY-----/g, '')\n .replace(/\\s/g, '');\n const der = Uint8Array.from(atob(pemContent), (c) => c.charCodeAt(0));\n\n const key = await globalThis.crypto.subtle.importKey(\n 'pkcs8',\n der,\n { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },\n false,\n ['sign'],\n );\n\n const signature = await globalThis.crypto.subtle.sign(\n 'RSASSA-PKCS1-v1_5',\n key,\n new TextEncoder().encode(signingInput),\n );\n\n return `${signingInput}.${base64urlFromBytes(new Uint8Array(signature))}`;\n}\n\nexport function parseServiceAccountJson(value: unknown): ServiceAccountKey {\n if (typeof value === 'object' && value !== null) {\n return serviceAccountKeySchema.parse(value);\n }\n if (typeof value !== 'string') {\n throw new Error(\n `serviceAccountJson must be a JSON object, raw JSON string, or base64-encoded JSON, but received ${typeof value}`,\n );\n }\n const trimmed = value.trim();\n if (trimmed.startsWith('{')) {\n return serviceAccountKeySchema.parse(JSON.parse(trimmed));\n }\n const binary = atob(trimmed);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n const decoded = new TextDecoder().decode(bytes);\n return serviceAccountKeySchema.parse(JSON.parse(decoded));\n}\n\nexport async function buildServiceAccountJwt(\n serviceAccountJson: ServiceAccountInput,\n scope: string,\n): Promise<{ url: string; body: string }> {\n const sa = parseServiceAccountJson(serviceAccountJson);\n const now = Math.floor(Date.now() / 1000);\n const jwt = await signRS256JWT(\n {\n iss: sa.client_email,\n scope,\n aud: sa.token_uri ?? 'https://oauth2.googleapis.com/token',\n exp: now + 3600,\n iat: now,\n },\n sa.private_key,\n );\n\n const body = new URLSearchParams({\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n assertion: jwt,\n }).toString();\n\n return {\n url: sa.token_uri ?? 'https://oauth2.googleapis.com/token',\n body,\n };\n}\n\nexport interface RefreshTokenCredentials {\n refreshToken: string;\n clientId: string;\n clientSecret: string;\n}\n\nexport function buildRefreshTokenGrant(credentials: RefreshTokenCredentials): {\n url: string;\n body: string;\n} {\n const body = new URLSearchParams({\n grant_type: 'refresh_token',\n refresh_token: credentials.refreshToken,\n client_id: credentials.clientId,\n client_secret: credentials.clientSecret,\n }).toString();\n\n return { url: 'https://oauth2.googleapis.com/token', body };\n}\n","import { z } from 'zod';\n\nexport const gcpAuthConfigShape = {\n serviceAccountJson: z.object({ $secret: z.string().trim().min(1) }).meta({\n label: 'Service Account JSON',\n description:\n 'Contents of the JSON key file for a Google service account with the role required by this connector. Create one at Google Cloud -> IAM & Admin -> Service Accounts and store the JSON as a secret.',\n secret: true,\n }),\n} as const;\n\nexport interface GcpAuthConfig {\n serviceAccountJson: { $secret: string };\n}\n","import type { HttpResponse } from './types';\n\nexport type HttpErrorKind =\n | 'transient'\n | 'rate_limit'\n | 'auth'\n | 'upstream_bug'\n | 'client_bug';\n\nexport abstract class HttpClientError extends Error {\n abstract readonly kind: HttpErrorKind;\n readonly response?: HttpResponse;\n\n constructor(message: string, response?: HttpResponse) {\n super(message);\n this.name = new.target.name;\n this.response = response;\n }\n}\n\nexport class TransientError extends HttpClientError {\n readonly kind = 'transient' as const;\n}\n\nexport class RateLimitError extends HttpClientError {\n readonly kind = 'rate_limit' as const;\n readonly retryAfter?: Date;\n\n constructor(message: string, response?: HttpResponse, retryAfter?: Date) {\n super(message, response);\n this.retryAfter = retryAfter;\n }\n}\n\nexport class AuthError extends HttpClientError {\n readonly kind = 'auth' as const;\n}\n\nexport class UpstreamBugError extends HttpClientError {\n readonly kind = 'upstream_bug' as const;\n}\n\nexport class ClientBugError extends HttpClientError {\n readonly kind = 'client_bug' as const;\n}\n\nexport function classifyStatus(status: number): HttpErrorKind {\n if (status === 429) {\n return 'rate_limit';\n }\n if (status === 401 || status === 403) {\n return 'auth';\n }\n if (status === 408) {\n return 'transient';\n }\n if (status >= 500) {\n return 'upstream_bug';\n }\n if (status >= 400) {\n return 'client_bug';\n }\n return 'client_bug';\n}\n\nexport function errorForStatus(\n message: string,\n response: HttpResponse,\n retryAfter?: Date,\n): HttpClientError {\n const kind = classifyStatus(response.status);\n switch (kind) {\n case 'rate_limit':\n return new RateLimitError(message, response, retryAfter);\n case 'auth':\n return new AuthError(message, response);\n case 'transient':\n return new TransientError(message, response);\n case 'upstream_bug':\n return new UpstreamBugError(message, response);\n case 'client_bug':\n return new ClientBugError(message, response);\n }\n}\n","import { HttpClientError, RateLimitError, TransientError } from './errors';\n\nexport interface RetryPolicy {\n maxAttempts?: number;\n initialDelayMs?: number;\n maxDelayMs?: number;\n retryOn?: (status: number | null, err?: Error) => boolean;\n}\n\nexport const defaultRetryOn = (status: number | null, err?: Error): boolean => {\n if (err instanceof RateLimitError) {\n return true;\n }\n if (err instanceof TransientError) {\n return true;\n }\n if (status === null) {\n return err instanceof Error && !(err instanceof HttpClientError);\n }\n if (status === 408 || status === 429) {\n return true;\n }\n if (status >= 500) {\n return true;\n }\n return false;\n};\n\nexport function backoffDelayMs(\n attempt: number,\n policy: Required<Pick<RetryPolicy, 'initialDelayMs' | 'maxDelayMs'>>,\n): number {\n const base = policy.initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, policy.maxDelayMs);\n}\n\nexport function parseRetryAfter(\n headerValue: string | null,\n now: Date = new Date(),\n): Date | undefined {\n if (!headerValue) {\n return undefined;\n }\n const trimmed = headerValue.trim();\n if (/^\\d+$/.test(trimmed)) {\n return new Date(now.getTime() + Number(trimmed) * 1000);\n }\n const parsed = Date.parse(trimmed);\n if (Number.isNaN(parsed)) {\n return undefined;\n }\n return new Date(parsed);\n}\n\nexport function sleep(ms: number, signal?: AbortSignal): Promise<void> {\n if (signal?.aborted) {\n return Promise.reject(signal.reason ?? new Error('Aborted'));\n }\n return new Promise<void>((resolve, reject) => {\n const onAbort = () => {\n clearTimeout(timer);\n reject(signal!.reason ?? new Error('Aborted'));\n };\n const timer = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n","export const HTTP_CLIENT_VERSION = '0.0.0';\n\nexport const DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n\nexport function connectorUserAgent(connectorId: string): string {\n return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n}\n","import {\n AuthError,\n ClientBugError,\n HttpClientError,\n RateLimitError,\n TransientError,\n UpstreamBugError,\n errorForStatus,\n} from './errors';\nimport { defaultRetryOn, parseRetryAfter, sleep } from './retry';\nimport type { FetchLike, HttpMethod, HttpRequest, HttpResponse } from './types';\nimport { DEFAULT_USER_AGENT } from './version';\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_MAX_ATTEMPTS = 3;\nconst DEFAULT_INITIAL_DELAY_MS = 1000;\nconst DEFAULT_MAX_DELAY_MS = 60_000;\nconst OBSERVER_TIMEOUT_MS = 250;\n\nexport interface RequestObservation {\n url: string;\n method: HttpMethod;\n status: number;\n resource: string;\n requestId: string;\n body: unknown;\n}\n\nexport type RequestObserver = (\n event: RequestObservation,\n) => void | Promise<void>;\n\nexport interface RequestOptions {\n fetch?: FetchLike;\n observer?: RequestObserver;\n resource: string;\n requestId?: string;\n}\n\nasync function notifyObserver(\n observer: RequestObserver,\n event: RequestObservation,\n): Promise<void> {\n let result: void | Promise<void>;\n try {\n result = observer(event);\n } catch (err) {\n console.warn('[connector-shared] request observer threw:', err);\n return;\n }\n if (!(result instanceof Promise)) {\n return;\n }\n const guarded = result.catch((err) => {\n console.warn('[connector-shared] request observer rejected:', err);\n });\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<void>((resolve) => {\n timer = setTimeout(resolve, OBSERVER_TIMEOUT_MS);\n });\n try {\n await Promise.race([guarded, timeout]);\n } finally {\n if (timer) {\n clearTimeout(timer);\n }\n }\n}\n\nfunction newRequestId(): string {\n const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;\n if (c?.randomUUID) {\n return c.randomUUID();\n }\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\nfunction mergeHeaders(\n defaults: Record<string, string>,\n overrides: Record<string, string> | undefined,\n): Record<string, string> {\n const merged: Record<string, string> = {};\n for (const [k, v] of Object.entries(defaults)) {\n merged[k.toLowerCase()] = v;\n }\n if (overrides) {\n for (const [k, v] of Object.entries(overrides)) {\n merged[k.toLowerCase()] = v;\n }\n }\n return merged;\n}\n\nfunction linkTimeoutSignal(\n parent: AbortSignal | undefined,\n timeoutMs: number,\n): { signal: AbortSignal; cancel: () => void } {\n const controller = new AbortController();\n const onParentAbort = () => {\n controller.abort(parent?.reason);\n };\n if (parent) {\n if (parent.aborted) {\n controller.abort(parent.reason);\n } else {\n parent.addEventListener('abort', onParentAbort, { once: true });\n }\n }\n const timer = setTimeout(() => {\n controller.abort(new Error(`Request timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n return {\n signal: controller.signal,\n cancel: () => {\n clearTimeout(timer);\n if (parent) {\n parent.removeEventListener('abort', onParentAbort);\n }\n },\n };\n}\n\nasync function readBody(\n res: Response,\n parseJson: boolean,\n binary: boolean,\n): Promise<unknown> {\n if (res.status === 204 || res.status === 205) {\n return null;\n }\n if (binary) {\n return new Uint8Array(await res.arrayBuffer());\n }\n const contentType = res.headers.get('content-type') ?? '';\n if (parseJson && contentType.includes('application/json')) {\n const text = await res.text();\n if (text.length === 0) {\n return null;\n }\n return JSON.parse(text);\n }\n return res.text();\n}\n\nexport async function request<T = unknown>(\n req: HttpRequest,\n options: RequestOptions,\n): Promise<HttpResponse<T>> {\n const fetchImpl: FetchLike = options.fetch ?? (globalThis.fetch as FetchLike);\n const retry = req.retry ?? {};\n const maxAttempts = retry.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const initialDelayMs = retry.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;\n const maxDelayMs = retry.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;\n const retryOn = retry.retryOn ?? defaultRetryOn;\n const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const parseJson = req.parseJson ?? true;\n const binary = req.binary ?? false;\n\n const headers = mergeHeaders(\n {\n 'User-Agent': DEFAULT_USER_AGENT,\n Accept: 'application/json',\n },\n req.headers,\n );\n\n let lastErr: Error | undefined;\n\n for (let attempt = 0; attempt < maxAttempts; attempt++) {\n req.signal?.throwIfAborted();\n\n const { signal, cancel } = linkTimeoutSignal(req.signal, timeoutMs);\n let res: Response;\n try {\n res = await fetchImpl(req.url, {\n method: req.method ?? 'GET',\n headers,\n body: req.body as RequestInit['body'],\n signal,\n });\n } catch (err) {\n cancel();\n if (req.signal?.aborted) {\n throw req.signal.reason ?? err;\n }\n const error = err instanceof Error ? err : new Error(String(err));\n lastErr = error;\n if (attempt < maxAttempts - 1 && retryOn(null, error)) {\n const delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n await sleep(delay, req.signal);\n continue;\n }\n throw new TransientError(error.message);\n }\n cancel();\n\n const body = await readBody(res, parseJson, binary);\n const httpResponse: HttpResponse<T> = {\n status: res.status,\n headers: res.headers,\n body: body as T,\n };\n if (req.rateLimit) {\n const state = req.rateLimit.parse(res.headers);\n if (state) {\n httpResponse.rateLimitState = state;\n }\n }\n\n if (options.observer) {\n await notifyObserver(options.observer, {\n url: req.url,\n method: req.method ?? 'GET',\n status: res.status,\n resource: options.resource,\n requestId: options.requestId ?? newRequestId(),\n body,\n });\n }\n\n if (res.ok) {\n return httpResponse;\n }\n\n const retryAfter = parseRetryAfter(res.headers.get('retry-after'));\n const message = `HTTP ${res.status} ${res.statusText} for ${req.method ?? 'GET'} ${req.url}`;\n const err = errorForStatus(message, httpResponse, retryAfter);\n\n if (\n attempt < maxAttempts - 1 &&\n retryOn(res.status, err) &&\n !(err instanceof AuthError) &&\n !(err instanceof ClientBugError)\n ) {\n lastErr = err;\n let delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n if (err instanceof RateLimitError && retryAfter) {\n const wait = retryAfter.getTime() - Date.now();\n if (wait > 0) {\n delay = Math.min(wait, maxDelayMs);\n }\n }\n await sleep(delay, req.signal);\n continue;\n }\n\n throw err;\n }\n\n throw lastErr ?? new UpstreamBugError('Exhausted retry attempts');\n}\n\nfunction computeDelay(\n attempt: number,\n initialDelayMs: number,\n maxDelayMs: number,\n): number {\n const base = initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, maxDelayMs);\n}\n\nexport { HttpClientError };\n","export interface RateLimitState {\n remaining: number;\n resetAt: Date;\n}\n\nexport interface RateLimitPolicy {\n parse(headers: Headers): RateLimitState | null;\n}\n\nexport interface StandardRateLimitPolicyConfig {\n remainingHeader: string;\n resetHeader: string;\n resetUnit: 's' | 'ms';\n resetFallbackMs?: number;\n}\n\nexport function standardRateLimitPolicy(\n config: StandardRateLimitPolicyConfig,\n): RateLimitPolicy {\n const { remainingHeader, resetHeader, resetUnit, resetFallbackMs } = config;\n const multiplier = resetUnit === 's' ? 1000 : 1;\n return {\n parse(h) {\n const remainingRaw = h.get(remainingHeader);\n if (remainingRaw === null || remainingRaw.trim() === '') {\n return null;\n }\n const remaining = Number(remainingRaw);\n if (!Number.isFinite(remaining)) {\n return null;\n }\n const resetRaw = h.get(resetHeader);\n if (resetRaw === null) {\n if (resetFallbackMs === undefined) {\n return null;\n }\n return {\n remaining,\n resetAt: new Date(Date.now() + resetFallbackMs),\n };\n }\n if (resetRaw.trim() === '') {\n return null;\n }\n const reset = Number(resetRaw);\n if (!Number.isFinite(reset) || reset < 0) {\n return null;\n }\n const resetMs = reset * multiplier;\n if (!Number.isFinite(resetMs)) {\n return null;\n }\n return { remaining, resetAt: new Date(resetMs) };\n },\n };\n}\n","export async function mapWithConcurrency<T, R>(\n items: readonly T[],\n concurrency: number,\n fn: (item: T, index: number) => Promise<R>,\n): Promise<R[]> {\n const results = new Array<R>(items.length);\n if (items.length === 0) {\n return results;\n }\n const normalized = Number.isFinite(concurrency) ? Math.floor(concurrency) : 1;\n const limit = Math.max(1, Math.min(normalized, items.length));\n let next = 0;\n let failed = false;\n\n async function worker(): Promise<void> {\n while (!failed) {\n const i = next++;\n if (i >= items.length) {\n return;\n }\n try {\n results[i] = await fn(items[i]!, i);\n } catch (err) {\n failed = true;\n throw err;\n }\n }\n }\n\n const workers: Promise<void>[] = [];\n for (let w = 0; w < limit; w++) {\n workers.push(worker());\n }\n await Promise.all(workers);\n return results;\n}\n","export interface SanitizeAllowedUrlOptions {\n url: string | null;\n host: string;\n pathname: string;\n protocol?: 'https:' | 'http:';\n}\n\nexport function sanitizeAllowedUrl(\n options: SanitizeAllowedUrlOptions,\n): string | null {\n const { url, host, pathname, protocol = 'https:' } = options;\n if (url === null) {\n return null;\n }\n try {\n const u = new URL(url);\n if (u.protocol !== protocol || u.host !== host || u.pathname !== pathname) {\n return null;\n }\n return u.toString();\n } catch {\n return null;\n }\n}\n","export type EpochUnit = 'ms' | 's' | 'iso';\n\nexport function parseEpoch(\n value: number | string | null | undefined,\n unit: EpochUnit,\n): number | null {\n if (value === null || value === undefined) {\n return null;\n }\n if (unit === 'iso') {\n if (typeof value !== 'string') {\n return null;\n }\n const ms = new Date(value).getTime();\n return Number.isFinite(ms) ? ms : null;\n }\n if (typeof value === 'string' && value.trim() === '') {\n return null;\n }\n const n = typeof value === 'number' ? value : Number(value);\n if (!Number.isFinite(n)) {\n return null;\n }\n const result = unit === 's' ? n * 1000 : n;\n return Number.isFinite(result) ? result : null;\n}\n","import { request } from './request';\nimport type { HttpRequest } from './types';\n\nexport function parseLinkHeader(header: string | null): Record<string, string> {\n if (!header) {\n return {};\n }\n const result: Record<string, string> = {};\n for (const part of header.split(',')) {\n const match = part.match(/<([^>]+)>\\s*;\\s*rel=\"([^\"]+)\"/);\n if (match) {\n result[match[2]!] = match[1]!;\n }\n }\n return result;\n}\n\nexport async function* paginateLink<T>(\n initial: HttpRequest,\n parse: (body: unknown) => T[],\n options: { resource: string },\n): AsyncIterable<T> {\n let next: string | null = initial.url;\n while (next) {\n const res: Awaited<ReturnType<typeof request>> = await request(\n {\n ...initial,\n url: next,\n },\n { resource: options.resource },\n );\n for (const item of parse(res.body)) {\n yield item;\n }\n const links = parseLinkHeader(res.headers.get('link'));\n next = links['next'] ?? null;\n }\n}\n\nexport async function* paginateCursor<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; nextCursor: string | null },\n buildNext: (req: HttpRequest, cursor: string) => HttpRequest,\n options: { resource: string },\n): AsyncIterable<T> {\n let req: HttpRequest = initial;\n while (true) {\n const res = await request(req, { resource: options.resource });\n const { items, nextCursor } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!nextCursor) {\n return;\n }\n req = buildNext(req, nextCursor);\n }\n}\n\nexport async function* paginatePage<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; hasMore: boolean },\n buildPage: (req: HttpRequest, page: number) => HttpRequest,\n options: { resource: string },\n): AsyncIterable<T> {\n let page = 1;\n while (true) {\n const req = page === 1 ? initial : buildPage(initial, page);\n const res = await request(req, { resource: options.resource });\n const { items, hasMore } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!hasMore || items.length === 0) {\n return;\n }\n page++;\n }\n}\n","export type LogFields = Record<string, unknown>;\n\nexport interface ConnectorLogger {\n info(event: string, fields?: LogFields): void;\n warn(event: string, fields?: LogFields): void;\n}\n\nexport interface ConnectorLoggerOptions {\n scope: string;\n}\n\nconst MAX_VALUE_LEN = 120;\n\nfunction truncate(s: string, max = MAX_VALUE_LEN): string {\n if (s.length <= max) {\n return s;\n }\n return `${s.slice(0, max - 1)}…`;\n}\n\nfunction formatValue(value: unknown): string {\n if (value === null) {\n return 'null';\n }\n if (value === undefined) {\n return '';\n }\n if (typeof value === 'number' || typeof value === 'boolean') {\n return String(value);\n }\n if (typeof value === 'string') {\n const t = truncate(value);\n if (/[\\s\"=]/.test(t)) {\n return JSON.stringify(t);\n }\n return t;\n }\n if (typeof value === 'bigint') {\n return value.toString();\n }\n let json: string | undefined;\n try {\n json = JSON.stringify(value);\n } catch {\n json = undefined;\n }\n return truncate(json ?? String(value));\n}\n\nexport function formatLogFields(fields?: LogFields): string {\n if (!fields) {\n return '';\n }\n const parts: string[] = [];\n for (const [k, v] of Object.entries(fields)) {\n if (v === undefined) {\n continue;\n }\n parts.push(`${k}=${formatValue(v)}`);\n }\n return parts.length > 0 ? ` ${parts.join(' ')}` : '';\n}\n\nexport function formatLogLine(\n scope: string,\n event: string,\n fields?: LogFields,\n): string {\n return `[${scope}] ${event}${formatLogFields(fields)}`;\n}\n\nexport function createDefaultConnectorLogger(\n opts: ConnectorLoggerOptions,\n): ConnectorLogger {\n return {\n info(event, fields) {\n console.info(formatLogLine(opts.scope, event, fields));\n },\n warn(event, fields) {\n console.warn(formatLogLine(opts.scope, event, fields));\n },\n };\n}\n\nconst NOOP_LOGGER: ConnectorLogger = {\n info() {},\n warn() {},\n};\n\nexport function noopConnectorLogger(): ConnectorLogger {\n return NOOP_LOGGER;\n}\n","import { parseEpoch } from '@rawdash/connector-shared';\nimport { z } from 'zod';\n\nexport const BQ_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;\nexport const BQ_DATASET_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;\n\nexport const BQ_API_BASE = 'https://bigquery.googleapis.com/bigquery/v2';\nexport const BQ_READONLY_SCOPE =\n 'https://www.googleapis.com/auth/bigquery.readonly';\nexport const BQ_PAGE_SIZE = 10_000;\nexport const BQ_QUERY_TIMEOUT_MS = 30_000;\n\nexport const bqQueryResponseSchema = z.object({\n jobComplete: z.boolean().optional(),\n schema: z\n .object({\n fields: z.array(z.object({ name: z.string(), type: z.string() })),\n })\n .optional(),\n rows: z\n .array(\n z.object({\n f: z.array(z.object({ v: z.string().nullable().optional() })),\n }),\n )\n .optional(),\n pageToken: z.string().optional(),\n jobReference: z\n .object({\n projectId: z.string(),\n jobId: z.string(),\n location: z.string().optional(),\n })\n .optional(),\n});\n\nexport type BqQueryResponse = z.infer<typeof bqQueryResponseSchema>;\nexport type BqJobReference = NonNullable<BqQueryResponse['jobReference']>;\n\nexport type BqPageRequest =\n | { method: 'POST'; url: string; body: string }\n | { method: 'GET'; url: string };\n\nexport interface BqPageLogger {\n info(message: string, meta?: Record<string, unknown>): void;\n warn(message: string, meta?: Record<string, unknown>): void;\n}\n\nexport function buildBigQueryPageRequest(opts: {\n projectId: string;\n sql: string;\n pageToken: string | undefined;\n jobReference: BqJobReference | undefined;\n location?: string;\n pageSize?: number;\n timeoutMs?: number;\n}): BqPageRequest {\n const pageSize = opts.pageSize ?? BQ_PAGE_SIZE;\n const timeoutMs = opts.timeoutMs ?? BQ_QUERY_TIMEOUT_MS;\n\n if (opts.pageToken === undefined) {\n const url = `${BQ_API_BASE}/projects/${encodeURIComponent(\n opts.projectId,\n )}/queries`;\n const body: Record<string, unknown> = {\n query: opts.sql,\n useLegacySql: false,\n maxResults: pageSize,\n timeoutMs,\n };\n if (opts.location !== undefined) {\n body['location'] = opts.location;\n }\n return { method: 'POST', url, body: JSON.stringify(body) };\n }\n\n if (opts.jobReference === undefined) {\n throw new Error(\n 'cannot fetch the next page of BigQuery results without a jobReference',\n );\n }\n\n const params = new URLSearchParams({\n pageToken: opts.pageToken,\n maxResults: String(pageSize),\n timeoutMs: String(timeoutMs),\n });\n const location = opts.jobReference.location ?? opts.location;\n if (location !== undefined) {\n params.set('location', location);\n }\n const url = `${BQ_API_BASE}/projects/${encodeURIComponent(\n opts.jobReference.projectId,\n )}/queries/${encodeURIComponent(opts.jobReference.jobId)}?${params.toString()}`;\n return { method: 'GET', url };\n}\n\nexport async function collectBigQueryPages<T>(opts: {\n projectId: string;\n sql: string;\n resource: string;\n fetchPage: (\n request: BqPageRequest,\n signal: AbortSignal | undefined,\n ) => Promise<BqQueryResponse>;\n mapRows: (response: BqQueryResponse) => T[];\n jobIncompleteMessage: string;\n location?: string;\n pageSize?: number;\n signal?: AbortSignal;\n logger?: BqPageLogger;\n}): Promise<{ rows: T[]; aborted: boolean }> {\n const rows: T[] = [];\n let pageToken: string | undefined;\n let jobReference: BqJobReference | undefined;\n let page = 0;\n const phaseStart = Date.now();\n\n do {\n if (opts.signal?.aborted) {\n return { rows, aborted: true };\n }\n const request = buildBigQueryPageRequest({\n projectId: opts.projectId,\n sql: opts.sql,\n pageToken,\n jobReference,\n location: opts.location,\n pageSize: opts.pageSize,\n });\n let response: BqQueryResponse;\n try {\n response = await opts.fetchPage(request, opts.signal);\n } catch (err) {\n opts.logger?.warn('fetch page failed', {\n resource: opts.resource,\n page: page + 1,\n error: err instanceof Error ? err.message : String(err),\n });\n throw err;\n }\n if (response.jobComplete === false) {\n throw new Error(opts.jobIncompleteMessage);\n }\n if (response.jobReference !== undefined) {\n jobReference = response.jobReference;\n }\n const pageRows = opts.mapRows(response);\n rows.push(...pageRows);\n pageToken =\n typeof response.pageToken === 'string' && response.pageToken.length > 0\n ? response.pageToken\n : undefined;\n page += 1;\n opts.logger?.info('fetched page', {\n resource: opts.resource,\n page,\n items: pageRows.length,\n next: pageToken ?? null,\n });\n } while (pageToken !== undefined);\n\n opts.logger?.info('resource done', {\n resource: opts.resource,\n pages: page,\n items: rows.length,\n duration_ms: Date.now() - phaseStart,\n });\n return { rows, aborted: false };\n}\n\nexport function indexBqFields(\n response: BqQueryResponse,\n): Record<string, number> {\n const fieldIndex: Record<string, number> = {};\n (response.schema?.fields ?? []).forEach((field, idx) => {\n fieldIndex[field.name] = idx;\n });\n return fieldIndex;\n}\n\nexport function readBqCell(\n cells: ReadonlyArray<{ v?: string | null }>,\n fieldIndex: Record<string, number>,\n name: string,\n): string | null {\n const idx = fieldIndex[name];\n if (idx === undefined) {\n return null;\n }\n const raw = cells[idx]?.v;\n if (raw === undefined || raw === null) {\n return null;\n }\n return raw;\n}\n\nexport function parseBqDateOrEpoch(value: string): number | null {\n const dateMatch = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(value);\n if (dateMatch) {\n return Date.UTC(\n Number(dateMatch[1]),\n Number(dateMatch[2]) - 1,\n Number(dateMatch[3]),\n );\n }\n return parseEpoch(value, 'iso');\n}\n","import { AuthError } from '@rawdash/connector-shared';\n\nimport {\n type RefreshTokenCredentials,\n type ServiceAccountInput,\n buildRefreshTokenGrant,\n buildServiceAccountJwt,\n} from './auth';\n\ninterface GcpTokenResponse {\n access_token: string;\n expires_in?: number;\n}\n\nexport type GcpTokenPoster = (\n url: string,\n opts: {\n resource: string;\n headers: Record<string, string>;\n body: string;\n signal?: AbortSignal;\n },\n) => Promise<{ body: GcpTokenResponse }>;\n\nexport class GcpAccessTokenProvider {\n private cached: { token: string; expiresAt: number } | null = null;\n\n constructor(\n private readonly opts: {\n connectorId: string;\n scope: string;\n getServiceAccountJson: () => ServiceAccountInput | undefined;\n getRefreshTokenCredentials?: () => RefreshTokenCredentials | undefined;\n post: GcpTokenPoster;\n },\n ) {}\n\n private async resolveGrant(): Promise<{ url: string; body: string }> {\n const serviceAccountJson = this.opts.getServiceAccountJson();\n if (serviceAccountJson) {\n return buildServiceAccountJwt(serviceAccountJson, this.opts.scope);\n }\n const refreshTokenCredentials = this.opts.getRefreshTokenCredentials?.();\n if (refreshTokenCredentials) {\n return buildRefreshTokenGrant(refreshTokenCredentials);\n }\n throw new AuthError(\n `${this.opts.connectorId}: missing serviceAccountJson or refresh-token credentials`,\n );\n }\n\n async getToken(signal?: AbortSignal): Promise<string> {\n if (this.cached && Date.now() < this.cached.expiresAt) {\n return this.cached.token;\n }\n const { url, body } = await this.resolveGrant();\n const res = await this.opts.post(url, {\n resource: 'oauth_token',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body,\n signal,\n });\n const expiresIn = res.body.expires_in ?? 3600;\n this.cached = {\n token: res.body.access_token,\n expiresAt: Date.now() + (expiresIn - 60) * 1000,\n };\n return this.cached.token;\n }\n}\n","export const MS_PER_DAY = 86_400_000;\n\nfunction pad2(n: number): string {\n return String(n).padStart(2, '0');\n}\n\nexport function toDateStr(ms: number): string {\n const d = new Date(ms);\n return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;\n}\n\nexport function startOfUtcDay(ms: number): number {\n return Math.floor(ms / MS_PER_DAY) * MS_PER_DAY;\n}\n","import type { HttpResponse } from './types';\n\nexport type HttpErrorKind =\n | 'transient'\n | 'rate_limit'\n | 'auth'\n | 'upstream_bug'\n | 'client_bug';\n\nexport abstract class HttpClientError extends Error {\n abstract readonly kind: HttpErrorKind;\n readonly response?: HttpResponse;\n\n constructor(message: string, response?: HttpResponse) {\n super(message);\n this.name = new.target.name;\n this.response = response;\n }\n}\n\nexport class TransientError extends HttpClientError {\n readonly kind = 'transient' as const;\n}\n\nexport class RateLimitError extends HttpClientError {\n readonly kind = 'rate_limit' as const;\n readonly retryAfter?: Date;\n\n constructor(message: string, response?: HttpResponse, retryAfter?: Date) {\n super(message, response);\n this.retryAfter = retryAfter;\n }\n}\n\nexport class AuthError extends HttpClientError {\n readonly kind = 'auth' as const;\n}\n\nexport class UpstreamBugError extends HttpClientError {\n readonly kind = 'upstream_bug' as const;\n}\n\nexport class ClientBugError extends HttpClientError {\n readonly kind = 'client_bug' as const;\n}\n\nexport function classifyStatus(status: number): HttpErrorKind {\n if (status === 429) {\n return 'rate_limit';\n }\n if (status === 401 || status === 403) {\n return 'auth';\n }\n if (status === 408) {\n return 'transient';\n }\n if (status >= 500) {\n return 'upstream_bug';\n }\n if (status >= 400) {\n return 'client_bug';\n }\n return 'client_bug';\n}\n\nexport function errorForStatus(\n message: string,\n response: HttpResponse,\n retryAfter?: Date,\n): HttpClientError {\n const kind = classifyStatus(response.status);\n switch (kind) {\n case 'rate_limit':\n return new RateLimitError(message, response, retryAfter);\n case 'auth':\n return new AuthError(message, response);\n case 'transient':\n return new TransientError(message, response);\n case 'upstream_bug':\n return new UpstreamBugError(message, response);\n case 'client_bug':\n return new ClientBugError(message, response);\n }\n}\n","import { HttpClientError, RateLimitError, TransientError } from './errors';\n\nexport interface RetryPolicy {\n maxAttempts?: number;\n initialDelayMs?: number;\n maxDelayMs?: number;\n retryOn?: (status: number | null, err?: Error) => boolean;\n}\n\nexport const defaultRetryOn = (status: number | null, err?: Error): boolean => {\n if (err instanceof RateLimitError) {\n return true;\n }\n if (err instanceof TransientError) {\n return true;\n }\n if (status === null) {\n return err instanceof Error && !(err instanceof HttpClientError);\n }\n if (status === 408 || status === 429) {\n return true;\n }\n if (status >= 500) {\n return true;\n }\n return false;\n};\n\nexport function backoffDelayMs(\n attempt: number,\n policy: Required<Pick<RetryPolicy, 'initialDelayMs' | 'maxDelayMs'>>,\n): number {\n const base = policy.initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, policy.maxDelayMs);\n}\n\nexport function parseRetryAfter(\n headerValue: string | null,\n now: Date = new Date(),\n): Date | undefined {\n if (!headerValue) {\n return undefined;\n }\n const trimmed = headerValue.trim();\n if (/^\\d+$/.test(trimmed)) {\n return new Date(now.getTime() + Number(trimmed) * 1000);\n }\n const parsed = Date.parse(trimmed);\n if (Number.isNaN(parsed)) {\n return undefined;\n }\n return new Date(parsed);\n}\n\nexport function sleep(ms: number, signal?: AbortSignal): Promise<void> {\n if (signal?.aborted) {\n return Promise.reject(signal.reason ?? new Error('Aborted'));\n }\n return new Promise<void>((resolve, reject) => {\n const onAbort = () => {\n clearTimeout(timer);\n reject(signal!.reason ?? new Error('Aborted'));\n };\n const timer = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n","export const HTTP_CLIENT_VERSION = '0.0.0';\n\nexport const DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n\nexport function connectorUserAgent(connectorId: string): string {\n return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n}\n","import {\n AuthError,\n ClientBugError,\n HttpClientError,\n RateLimitError,\n TransientError,\n UpstreamBugError,\n errorForStatus,\n} from './errors';\nimport { defaultRetryOn, parseRetryAfter, sleep } from './retry';\nimport type { FetchLike, HttpMethod, HttpRequest, HttpResponse } from './types';\nimport { DEFAULT_USER_AGENT } from './version';\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_MAX_ATTEMPTS = 3;\nconst DEFAULT_INITIAL_DELAY_MS = 1000;\nconst DEFAULT_MAX_DELAY_MS = 60_000;\nconst OBSERVER_TIMEOUT_MS = 250;\n\nexport interface RequestObservation {\n url: string;\n method: HttpMethod;\n status: number;\n resource: string;\n requestId: string;\n body: unknown;\n}\n\nexport type RequestObserver = (\n event: RequestObservation,\n) => void | Promise<void>;\n\nexport interface RequestOptions {\n fetch?: FetchLike;\n observer?: RequestObserver;\n resource: string;\n requestId?: string;\n}\n\nasync function notifyObserver(\n observer: RequestObserver,\n event: RequestObservation,\n): Promise<void> {\n let result: void | Promise<void>;\n try {\n result = observer(event);\n } catch (err) {\n console.warn('[connector-shared] request observer threw:', err);\n return;\n }\n if (!(result instanceof Promise)) {\n return;\n }\n const guarded = result.catch((err) => {\n console.warn('[connector-shared] request observer rejected:', err);\n });\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<void>((resolve) => {\n timer = setTimeout(resolve, OBSERVER_TIMEOUT_MS);\n });\n try {\n await Promise.race([guarded, timeout]);\n } finally {\n if (timer) {\n clearTimeout(timer);\n }\n }\n}\n\nfunction newRequestId(): string {\n const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;\n if (c?.randomUUID) {\n return c.randomUUID();\n }\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\nfunction mergeHeaders(\n defaults: Record<string, string>,\n overrides: Record<string, string> | undefined,\n): Record<string, string> {\n const merged: Record<string, string> = {};\n for (const [k, v] of Object.entries(defaults)) {\n merged[k.toLowerCase()] = v;\n }\n if (overrides) {\n for (const [k, v] of Object.entries(overrides)) {\n merged[k.toLowerCase()] = v;\n }\n }\n return merged;\n}\n\nfunction linkTimeoutSignal(\n parent: AbortSignal | undefined,\n timeoutMs: number,\n): { signal: AbortSignal; cancel: () => void } {\n const controller = new AbortController();\n const onParentAbort = () => {\n controller.abort(parent?.reason);\n };\n if (parent) {\n if (parent.aborted) {\n controller.abort(parent.reason);\n } else {\n parent.addEventListener('abort', onParentAbort, { once: true });\n }\n }\n const timer = setTimeout(() => {\n controller.abort(new Error(`Request timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n return {\n signal: controller.signal,\n cancel: () => {\n clearTimeout(timer);\n if (parent) {\n parent.removeEventListener('abort', onParentAbort);\n }\n },\n };\n}\n\nasync function readBody(\n res: Response,\n parseJson: boolean,\n binary: boolean,\n): Promise<unknown> {\n if (res.status === 204 || res.status === 205) {\n return null;\n }\n if (binary) {\n return new Uint8Array(await res.arrayBuffer());\n }\n const contentType = res.headers.get('content-type') ?? '';\n if (parseJson && contentType.includes('application/json')) {\n const text = await res.text();\n if (text.length === 0) {\n return null;\n }\n return JSON.parse(text);\n }\n return res.text();\n}\n\nexport async function request<T = unknown>(\n req: HttpRequest,\n options: RequestOptions,\n): Promise<HttpResponse<T>> {\n const fetchImpl: FetchLike = options.fetch ?? (globalThis.fetch as FetchLike);\n const retry = req.retry ?? {};\n const maxAttempts = retry.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const initialDelayMs = retry.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;\n const maxDelayMs = retry.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;\n const retryOn = retry.retryOn ?? defaultRetryOn;\n const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const parseJson = req.parseJson ?? true;\n const binary = req.binary ?? false;\n\n const headers = mergeHeaders(\n {\n 'User-Agent': DEFAULT_USER_AGENT,\n Accept: 'application/json',\n },\n req.headers,\n );\n\n let lastErr: Error | undefined;\n\n for (let attempt = 0; attempt < maxAttempts; attempt++) {\n req.signal?.throwIfAborted();\n\n const { signal, cancel } = linkTimeoutSignal(req.signal, timeoutMs);\n let res: Response;\n try {\n res = await fetchImpl(req.url, {\n method: req.method ?? 'GET',\n headers,\n body: req.body as RequestInit['body'],\n signal,\n });\n } catch (err) {\n cancel();\n if (req.signal?.aborted) {\n throw req.signal.reason ?? err;\n }\n const error = err instanceof Error ? err : new Error(String(err));\n lastErr = error;\n if (attempt < maxAttempts - 1 && retryOn(null, error)) {\n const delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n await sleep(delay, req.signal);\n continue;\n }\n throw new TransientError(error.message);\n }\n cancel();\n\n const body = await readBody(res, parseJson, binary);\n const httpResponse: HttpResponse<T> = {\n status: res.status,\n headers: res.headers,\n body: body as T,\n };\n if (req.rateLimit) {\n const state = req.rateLimit.parse(res.headers);\n if (state) {\n httpResponse.rateLimitState = state;\n }\n }\n\n if (options.observer) {\n await notifyObserver(options.observer, {\n url: req.url,\n method: req.method ?? 'GET',\n status: res.status,\n resource: options.resource,\n requestId: options.requestId ?? newRequestId(),\n body,\n });\n }\n\n if (res.ok) {\n return httpResponse;\n }\n\n const retryAfter = parseRetryAfter(res.headers.get('retry-after'));\n const message = `HTTP ${res.status} ${res.statusText} for ${req.method ?? 'GET'} ${req.url}`;\n const err = errorForStatus(message, httpResponse, retryAfter);\n\n if (\n attempt < maxAttempts - 1 &&\n retryOn(res.status, err) &&\n !(err instanceof AuthError) &&\n !(err instanceof ClientBugError)\n ) {\n lastErr = err;\n let delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n if (err instanceof RateLimitError && retryAfter) {\n const wait = retryAfter.getTime() - Date.now();\n if (wait > 0) {\n delay = Math.min(wait, maxDelayMs);\n }\n }\n await sleep(delay, req.signal);\n continue;\n }\n\n throw err;\n }\n\n throw lastErr ?? new UpstreamBugError('Exhausted retry attempts');\n}\n\nfunction computeDelay(\n attempt: number,\n initialDelayMs: number,\n maxDelayMs: number,\n): number {\n const base = initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, maxDelayMs);\n}\n\nexport { HttpClientError };\n","export interface RateLimitState {\n remaining: number;\n resetAt: Date;\n}\n\nexport interface RateLimitPolicy {\n parse(headers: Headers): RateLimitState | null;\n}\n\nexport interface StandardRateLimitPolicyConfig {\n remainingHeader: string;\n resetHeader: string;\n resetUnit: 's' | 'ms';\n resetFallbackMs?: number;\n}\n\nexport function standardRateLimitPolicy(\n config: StandardRateLimitPolicyConfig,\n): RateLimitPolicy {\n const { remainingHeader, resetHeader, resetUnit, resetFallbackMs } = config;\n const multiplier = resetUnit === 's' ? 1000 : 1;\n return {\n parse(h) {\n const remainingRaw = h.get(remainingHeader);\n if (remainingRaw === null || remainingRaw.trim() === '') {\n return null;\n }\n const remaining = Number(remainingRaw);\n if (!Number.isFinite(remaining)) {\n return null;\n }\n const resetRaw = h.get(resetHeader);\n if (resetRaw === null) {\n if (resetFallbackMs === undefined) {\n return null;\n }\n return {\n remaining,\n resetAt: new Date(Date.now() + resetFallbackMs),\n };\n }\n if (resetRaw.trim() === '') {\n return null;\n }\n const reset = Number(resetRaw);\n if (!Number.isFinite(reset) || reset < 0) {\n return null;\n }\n const resetMs = reset * multiplier;\n if (!Number.isFinite(resetMs)) {\n return null;\n }\n return { remaining, resetAt: new Date(resetMs) };\n },\n };\n}\n","export async function mapWithConcurrency<T, R>(\n items: readonly T[],\n concurrency: number,\n fn: (item: T, index: number) => Promise<R>,\n): Promise<R[]> {\n const results = new Array<R>(items.length);\n if (items.length === 0) {\n return results;\n }\n const normalized = Number.isFinite(concurrency) ? Math.floor(concurrency) : 1;\n const limit = Math.max(1, Math.min(normalized, items.length));\n let next = 0;\n let failed = false;\n\n async function worker(): Promise<void> {\n while (!failed) {\n const i = next++;\n if (i >= items.length) {\n return;\n }\n try {\n results[i] = await fn(items[i]!, i);\n } catch (err) {\n failed = true;\n throw err;\n }\n }\n }\n\n const workers: Promise<void>[] = [];\n for (let w = 0; w < limit; w++) {\n workers.push(worker());\n }\n await Promise.all(workers);\n return results;\n}\n","export interface SanitizeAllowedUrlOptions {\n url: string | null;\n host: string;\n pathname: string;\n protocol?: 'https:' | 'http:';\n}\n\nexport function sanitizeAllowedUrl(\n options: SanitizeAllowedUrlOptions,\n): string | null {\n const { url, host, pathname, protocol = 'https:' } = options;\n if (url === null) {\n return null;\n }\n try {\n const u = new URL(url);\n if (u.protocol !== protocol || u.host !== host || u.pathname !== pathname) {\n return null;\n }\n return u.toString();\n } catch {\n return null;\n }\n}\n","export type EpochUnit = 'ms' | 's' | 'iso';\n\nexport function parseEpoch(\n value: number | string | null | undefined,\n unit: EpochUnit,\n): number | null {\n if (value === null || value === undefined) {\n return null;\n }\n if (unit === 'iso') {\n if (typeof value !== 'string') {\n return null;\n }\n const ms = new Date(value).getTime();\n return Number.isFinite(ms) ? ms : null;\n }\n if (typeof value === 'string' && value.trim() === '') {\n return null;\n }\n const n = typeof value === 'number' ? value : Number(value);\n if (!Number.isFinite(n)) {\n return null;\n }\n const result = unit === 's' ? n * 1000 : n;\n return Number.isFinite(result) ? result : null;\n}\n","import { request } from './request';\nimport type { HttpRequest } from './types';\n\nexport function parseLinkHeader(header: string | null): Record<string, string> {\n if (!header) {\n return {};\n }\n const result: Record<string, string> = {};\n for (const part of header.split(',')) {\n const match = part.match(/<([^>]+)>\\s*;\\s*rel=\"([^\"]+)\"/);\n if (match) {\n result[match[2]!] = match[1]!;\n }\n }\n return result;\n}\n\nexport async function* paginateLink<T>(\n initial: HttpRequest,\n parse: (body: unknown) => T[],\n options: { resource: string },\n): AsyncIterable<T> {\n let next: string | null = initial.url;\n while (next) {\n const res: Awaited<ReturnType<typeof request>> = await request(\n {\n ...initial,\n url: next,\n },\n { resource: options.resource },\n );\n for (const item of parse(res.body)) {\n yield item;\n }\n const links = parseLinkHeader(res.headers.get('link'));\n next = links['next'] ?? null;\n }\n}\n\nexport async function* paginateCursor<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; nextCursor: string | null },\n buildNext: (req: HttpRequest, cursor: string) => HttpRequest,\n options: { resource: string },\n): AsyncIterable<T> {\n let req: HttpRequest = initial;\n while (true) {\n const res = await request(req, { resource: options.resource });\n const { items, nextCursor } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!nextCursor) {\n return;\n }\n req = buildNext(req, nextCursor);\n }\n}\n\nexport async function* paginatePage<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; hasMore: boolean },\n buildPage: (req: HttpRequest, page: number) => HttpRequest,\n options: { resource: string },\n): AsyncIterable<T> {\n let page = 1;\n while (true) {\n const req = page === 1 ? initial : buildPage(initial, page);\n const res = await request(req, { resource: options.resource });\n const { items, hasMore } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!hasMore || items.length === 0) {\n return;\n }\n page++;\n }\n}\n","export type LogFields = Record<string, unknown>;\n\nexport interface ConnectorLogger {\n info(event: string, fields?: LogFields): void;\n warn(event: string, fields?: LogFields): void;\n}\n\nexport interface ConnectorLoggerOptions {\n scope: string;\n}\n\nconst MAX_VALUE_LEN = 120;\n\nfunction truncate(s: string, max = MAX_VALUE_LEN): string {\n if (s.length <= max) {\n return s;\n }\n return `${s.slice(0, max - 1)}…`;\n}\n\nfunction formatValue(value: unknown): string {\n if (value === null) {\n return 'null';\n }\n if (value === undefined) {\n return '';\n }\n if (typeof value === 'number' || typeof value === 'boolean') {\n return String(value);\n }\n if (typeof value === 'string') {\n const t = truncate(value);\n if (/[\\s\"=]/.test(t)) {\n return JSON.stringify(t);\n }\n return t;\n }\n if (typeof value === 'bigint') {\n return value.toString();\n }\n let json: string | undefined;\n try {\n json = JSON.stringify(value);\n } catch {\n json = undefined;\n }\n return truncate(json ?? String(value));\n}\n\nexport function formatLogFields(fields?: LogFields): string {\n if (!fields) {\n return '';\n }\n const parts: string[] = [];\n for (const [k, v] of Object.entries(fields)) {\n if (v === undefined) {\n continue;\n }\n parts.push(`${k}=${formatValue(v)}`);\n }\n return parts.length > 0 ? ` ${parts.join(' ')}` : '';\n}\n\nexport function formatLogLine(\n scope: string,\n event: string,\n fields?: LogFields,\n): string {\n return `[${scope}] ${event}${formatLogFields(fields)}`;\n}\n\nexport function createDefaultConnectorLogger(\n opts: ConnectorLoggerOptions,\n): ConnectorLogger {\n return {\n info(event, fields) {\n console.info(formatLogLine(opts.scope, event, fields));\n },\n warn(event, fields) {\n console.warn(formatLogLine(opts.scope, event, fields));\n },\n };\n}\n\nconst NOOP_LOGGER: ConnectorLogger = {\n info() {},\n warn() {},\n};\n\nexport function noopConnectorLogger(): ConnectorLogger {\n return NOOP_LOGGER;\n}\n","import { GcpAccessTokenProvider } from '@rawdash/connector-gcp-shared';\nimport { connectorUserAgent } from '@rawdash/connector-shared';\nimport {\n BaseConnector,\n type ConnectorContext,\n type ConnectorDoc,\n type CredentialsSchema,\n type Entity,\n type StorageHandle,\n type SyncOptions,\n type SyncResult,\n defineConfigFields,\n defineConnectorDoc,\n defineResources,\n schemasFromResources,\n selectActivePhases,\n} from '@rawdash/core';\nimport { z } from 'zod';\n\nimport {\n INSTALLS_BREAKDOWNS,\n type InstallsBreakdown,\n type InstallsSample,\n decodeUtf16Csv,\n installsMonthsForRange,\n installsObjectPath,\n normalizeInstallsBucketId,\n parseInstallsCsv,\n} from './installs';\n\nexport const configFields = defineConfigFields(\n z.object({\n packageName: z\n .string()\n .trim()\n .regex(/^[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)+$/, {\n message:\n 'packageName must be a reverse-DNS application id (e.g. com.example.app).',\n })\n .meta({\n label: 'Package name',\n description:\n 'Reverse-DNS application id of the Android app (e.g. com.example.app). Visible in the Play Console URL and on Google Play under \"About\".',\n placeholder: 'com.example.app',\n }),\n serviceAccountJson: z.object({ $secret: z.string().trim().min(1) }).meta({\n label: 'Service Account JSON',\n description:\n 'Contents of the JSON key file for a Google service account that has been granted access to your Play Console developer account with at least the \"View app information and download bulk reports\" permission. Create one at Google Cloud -> IAM & Admin -> Service Accounts.',\n secret: true,\n }),\n lookbackDays: z.number().int().positive().optional().meta({\n label: 'Lookback days (full sync)',\n description:\n 'How many calendar days to fetch on a full sync. Defaults to 30. The Play Developer Reporting API exposes daily metrics with a typical 2-3 day reporting lag.',\n placeholder: '30',\n }),\n reviewLimit: z.number().int().positive().max(2000).optional().meta({\n label: 'Review sample size',\n description:\n 'How many of the most-recent user reviews to emit as gplay_app_ratings samples. Defaults to 200. Reviews are fetched then ranked newest-first before this cap is applied. The Android Publisher reviews API only surfaces reviews from roughly the past week, so this is a rolling sample, not a full history.',\n placeholder: '200',\n }),\n installsBucketId: z.string().trim().min(1).optional().meta({\n label: 'Installs report bucket id',\n description:\n 'Cloud Storage bucket id that holds your Play Console reports (e.g. `pubsite_prod_rev_01234567890987654321`), shown via \"Copy Cloud Storage URI\" on the Play Console Download reports page. Required only for the `gplay_installs_*` resources, which read the monthly stats/installs CSV reports. The bucket is Google-managed; the service account is granted access through Play Console (Users & permissions -> \"View app information and download bulk reports\", set to Global), not Google Cloud IAM.',\n placeholder: 'pubsite_prod_rev_01234567890987654321',\n }),\n }),\n);\n\nexport const doc: ConnectorDoc = defineConnectorDoc({\n displayName: 'Google Play Console',\n category: 'engineering',\n brandColor: '#34A853',\n tagline:\n 'Sync daily Android app vitals from the Play Developer Reporting API (crash rate, ANR rate, error counts) plus user review ratings from the Android Publisher API.',\n vendor: {\n name: 'Google Play Console',\n domain: 'play.google.com',\n apiDocs: 'https://developers.google.com/play/developer/reporting',\n website: 'https://play.google.com/console/',\n },\n auth: {\n summary:\n 'Authenticate against the Play Developer Reporting API and the Android Publisher API with a Google service account JSON key. The service account must be linked to your Play Console developer account.',\n setup: [\n 'In Google Cloud, create a service account at IAM & Admin -> Service Accounts and download a JSON key.',\n 'Enable both the \"Google Play Developer Reporting API\" and the \"Google Play Android Developer API\" on the Cloud project.',\n 'In Google Play Console open Setup -> API access, link the same Cloud project, then invite the service account email and grant it at least the \"View app information and download bulk reports\" permission for the app you want to sync.',\n 'For the `gplay_installs_*` resources, grant bucket access inside Play Console, not Google Cloud IAM: the install reports live in a Google-managed Cloud Storage bucket provisioned for your developer account. In Play Console -> Users & permissions, give the service account the account-level \"View app information and download bulk reports\" permission set to Global (changes can take a few hours to propagate), then copy the bucket id from the Download reports page (the Cloud Storage URI starts with `gs://pubsite_prod_...`) into installsBucketId.',\n 'Store the service account JSON as a secret and reference it as serviceAccountJson: secret(\"GPLAY_SA_JSON\").',\n 'Set packageName to the reverse-DNS application id of the app (e.g. com.example.app).',\n ],\n },\n rateLimit:\n 'The Play Developer Reporting API enforces a per-project quota (default 60 requests per minute); 429 responses are retried with exponential backoff.',\n limitations: [\n 'Daily vitals (crash rate, ANR rate, error counts) have a 2-3 day reporting lag on the Play Developer Reporting API; incremental syncs refetch the trailing 3 days. Metric days are reported on the America/Los_Angeles calendar, the only timezone the API supports for daily aggregation.',\n 'gplay_app_ratings is a rolling sample of recent reviews from the Android Publisher reviews API (default 200, configurable via reviewLimit). Each sample carries one review with its star rating (1-5) as the value; this is not the lifetime average shown on the Play Store, and the reviews API only surfaces reviews from roughly the past week.',\n 'The apps entity carries only the configured package name; the Play Store listing title is available solely through an Android Publisher edit, which this connector does not create.',\n 'The `gplay_installs_*` resources read the monthly stats/installs CSV reports from your Play Console Cloud Storage bucket, not the Reporting API; they require installsBucketId plus the account-level \"View app information and download bulk reports\" permission granted to the service account in Play Console (the bucket is Google-managed; access is not configured through Google Cloud IAM). Files are published monthly (with daily rows) and a few days in arrears, so the current month fills in over time and the most recent days lag. Earnings/financial reports remain out of scope.',\n ],\n});\n\nexport interface GooglePlayConsoleSettings {\n packageName: string;\n lookbackDays?: number;\n reviewLimit?: number;\n installsBucketId?: string;\n}\n\nconst gplayCredentials = {\n serviceAccountJson: {\n description: 'Google service account JSON key (base64 or raw JSON)',\n auth: 'required' as const,\n },\n} satisfies CredentialsSchema;\n\ntype GplayCredentials = typeof gplayCredentials;\n\nconst PHASE_ORDER = [\n 'apps',\n 'crash_rate',\n 'anr_rate',\n 'errors',\n 'installs_overview',\n 'installs_country',\n 'installs_app_version',\n 'installs_device',\n 'installs_os_version',\n 'installs_language',\n 'installs_carrier',\n 'reviews',\n] as const;\n\ntype GplayPhase = (typeof PHASE_ORDER)[number];\n\nconst INSTALLS_PHASE_TO_BREAKDOWN: Record<string, InstallsBreakdown> =\n Object.fromEntries(INSTALLS_BREAKDOWNS.map((b) => [b.phase, b]));\n\ntype MetricPhase = 'crash_rate' | 'anr_rate' | 'errors';\n\ninterface GplayDateRange {\n startDate: string;\n endDate: string;\n}\n\ninterface GplaySyncCursor {\n phase: GplayPhase;\n dateRange: GplayDateRange;\n}\n\nconst GPLAY_DATE_RE = /^\\d{4}-\\d{2}-\\d{2}$/;\n\nfunction isGplayDateString(value: unknown): value is string {\n return typeof value === 'string' && GPLAY_DATE_RE.test(value);\n}\n\nfunction isGplayDateRange(value: unknown): value is GplayDateRange {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n const v = value as { startDate?: unknown; endDate?: unknown };\n return isGplayDateString(v.startDate) && isGplayDateString(v.endDate);\n}\n\nfunction isGplaySyncCursor(value: unknown): value is GplaySyncCursor {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n const v = value as { phase?: unknown; dateRange?: unknown };\n if (typeof v.phase !== 'string') {\n return false;\n }\n if (!(PHASE_ORDER as readonly string[]).includes(v.phase)) {\n return false;\n }\n return isGplayDateRange(v.dateRange);\n}\n\ninterface MetricPhaseConfig {\n metricSet: string;\n metrics: string[];\n metricName: string;\n primaryMetric: string;\n responseTag: string;\n}\n\nconst METRIC_PHASE_CONFIGS: Record<MetricPhase, MetricPhaseConfig> = {\n crash_rate: {\n metricSet: 'crashRateMetricSet',\n metrics: ['crashRate', 'distinctUsers'],\n metricName: 'gplay_crash_rate_by_day',\n primaryMetric: 'crashRate',\n responseTag: 'crash_rate',\n },\n anr_rate: {\n metricSet: 'anrRateMetricSet',\n metrics: ['anrRate', 'distinctUsers'],\n metricName: 'gplay_anr_rate_by_day',\n primaryMetric: 'anrRate',\n responseTag: 'anr_rate',\n },\n errors: {\n metricSet: 'errorCountMetricSet',\n metrics: ['errorReportCount', 'distinctUsers'],\n metricName: 'gplay_error_count_by_day',\n primaryMetric: 'errorReportCount',\n responseTag: 'errors',\n },\n};\n\nconst GPLAY_APP_RATINGS_METRIC = 'gplay_app_ratings';\n\nconst RESOURCE_TO_PHASE: Record<string, GplayPhase> = {\n apps: 'apps',\n gplay_crash_rate_by_day: 'crash_rate',\n gplay_anr_rate_by_day: 'anr_rate',\n gplay_error_count_by_day: 'errors',\n [GPLAY_APP_RATINGS_METRIC]: 'reviews',\n ...Object.fromEntries(\n INSTALLS_BREAKDOWNS.map((b) => [b.resource, b.phase as GplayPhase]),\n ),\n};\n\nconst SCOPES = [\n 'https://www.googleapis.com/auth/playdeveloperreporting',\n 'https://www.googleapis.com/auth/androidpublisher',\n 'https://www.googleapis.com/auth/devstorage.read_only',\n].join(' ');\n\nconst REPORTING_BASE = 'https://playdeveloperreporting.googleapis.com';\nconst PUBLISHER_BASE = 'https://androidpublisher.googleapis.com';\nconst GCS_BASE = 'https://storage.googleapis.com';\nconst INSTALLS_DOWNLOAD_TIMEOUT_MS = 30_000;\n\nconst DAILY_TIME_ZONE = 'America/Los_Angeles';\n\nconst DEFAULT_REVIEW_LIMIT = 200;\nconst REVIEWS_PAGE_SIZE = 100;\nconst MAX_REVIEW_PAGES = 50;\n\nexport interface GplayTimelineDate {\n year?: number;\n month?: number;\n day?: number;\n}\n\nexport interface GplayTimelinePoint {\n startTime?: { year?: number; month?: number; day?: number };\n}\n\nexport interface GplayMetricValue {\n metric?: string;\n decimalValue?: { value?: string };\n decimalValueConfidenceInterval?: unknown;\n}\n\nexport interface GplayMetricRow {\n startTime?: { year?: number; month?: number; day?: number };\n metrics?: GplayMetricValue[];\n}\n\ninterface GplayMetricResponse {\n rows?: GplayMetricRow[];\n nextPageToken?: string;\n}\n\nexport interface GplayTimestamp {\n seconds?: string;\n nanos?: number;\n}\n\nexport interface GplayUserComment {\n text?: string;\n lastModified?: GplayTimestamp;\n starRating?: number;\n reviewerLanguage?: string;\n device?: string;\n androidOsVersion?: number;\n appVersionCode?: number;\n appVersionName?: string;\n}\n\nexport interface GplayReview {\n reviewId?: string;\n authorName?: string;\n comments?: Array<{ userComment?: GplayUserComment }>;\n}\n\ninterface GplayReviewsResponse {\n reviews?: GplayReview[];\n tokenPagination?: { nextPageToken?: string };\n}\n\nconst gplayDateFormatter = new Intl.DateTimeFormat('en-CA', {\n timeZone: DAILY_TIME_ZONE,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n});\n\nfunction toGplayDate(date: Date): string {\n return gplayDateFormatter.format(date);\n}\n\nfunction gplayDateToMs(gplayDate: string): number {\n const y = gplayDate.slice(0, 4);\n const m = gplayDate.slice(5, 7);\n const d = gplayDate.slice(8, 10);\n return Date.UTC(Number(y), Number(m) - 1, Number(d));\n}\n\nfunction partsToGplayDate(parts: {\n year?: number;\n month?: number;\n day?: number;\n}): string | null {\n const { year, month, day } = parts;\n if (\n typeof year !== 'number' ||\n typeof month !== 'number' ||\n typeof day !== 'number' ||\n !Number.isInteger(year) ||\n !Number.isInteger(month) ||\n !Number.isInteger(day) ||\n year < 1970 ||\n year > 2999 ||\n month < 1 ||\n month > 12 ||\n day < 1 ||\n day > 31\n ) {\n return null;\n }\n const y = String(year).padStart(4, '0');\n const m = String(month).padStart(2, '0');\n const d = String(day).padStart(2, '0');\n return `${y}-${m}-${d}`;\n}\n\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\nconst INCREMENTAL_LOOKBACK_DAYS = 3;\n\nfunction dateRangeToReplaceWindow(\n range: GplayDateRange,\n): { start: number; end: number } | undefined {\n const start = gplayDateToMs(range.startDate);\n const end = gplayDateToMs(range.endDate) + MS_PER_DAY - 1;\n if (start > end) {\n return undefined;\n }\n return { start, end };\n}\n\nfunction getDateRange(\n options: SyncOptions,\n lookbackDays: number,\n): GplayDateRange {\n const now = Date.now();\n const endDate = toGplayDate(new Date(now));\n if (options.mode === 'latest') {\n const startMs = now - (INCREMENTAL_LOOKBACK_DAYS - 1) * MS_PER_DAY;\n return { startDate: toGplayDate(new Date(startMs)), endDate };\n }\n if (options.since) {\n const sinceMs = new Date(options.since).getTime();\n if (Number.isFinite(sinceMs)) {\n const days = Math.max(1, Math.ceil((now - sinceMs) / MS_PER_DAY));\n const cappedDays = Math.min(days, lookbackDays);\n const startMs = now - (cappedDays - 1) * MS_PER_DAY;\n return { startDate: toGplayDate(new Date(startMs)), endDate };\n }\n }\n const startMs = now - (lookbackDays - 1) * MS_PER_DAY;\n return { startDate: toGplayDate(new Date(startMs)), endDate };\n}\n\nexport function rowToMetricSample(\n row: GplayMetricRow,\n metricsToCollect: string[],\n metricName: string,\n primaryMetric: string,\n packageName: string,\n): {\n name: string;\n ts: number;\n value: number;\n attributes: Record<string, string | number>;\n} | null {\n const dateStr = partsToGplayDate(row.startTime ?? {});\n if (!dateStr) {\n return null;\n }\n const attributes: Record<string, string | number> = {\n date: dateStr,\n package_name: packageName,\n };\n\n for (const m of metricsToCollect) {\n attributes[m] = 0;\n }\n for (const m of row.metrics ?? []) {\n if (!m.metric) {\n continue;\n }\n const raw = m.decimalValue?.value;\n const parsed = typeof raw === 'string' ? Number(raw) : NaN;\n attributes[m.metric] = Number.isFinite(parsed) ? parsed : 0;\n }\n\n const primary = attributes[primaryMetric];\n const value = typeof primary === 'number' ? primary : 0;\n\n return {\n name: metricName,\n ts: gplayDateToMs(dateStr),\n value,\n attributes,\n };\n}\n\nfunction timestampToMs(ts: GplayTimestamp | undefined): number | null {\n if (!ts || typeof ts.seconds !== 'string') {\n return null;\n }\n const seconds = Number(ts.seconds);\n if (!Number.isFinite(seconds)) {\n return null;\n }\n const nanos = typeof ts.nanos === 'number' ? ts.nanos : 0;\n return Math.round(seconds * 1000 + nanos / 1e6);\n}\n\nexport function reviewToRatingSample(\n review: GplayReview,\n packageName: string,\n): {\n name: string;\n ts: number;\n value: number;\n attributes: Record<string, string | number>;\n} | null {\n const userComment = review.comments?.find((c) => c.userComment)?.userComment;\n if (!userComment) {\n return null;\n }\n const rating = userComment.starRating;\n if (typeof rating !== 'number' || rating < 1 || rating > 5) {\n return null;\n }\n const ts = timestampToMs(userComment.lastModified);\n if (ts === null) {\n return null;\n }\n\n const attributes: Record<string, string | number> = {\n package_name: packageName,\n review_id: review.reviewId ?? '',\n };\n if (userComment.reviewerLanguage) {\n attributes['reviewer_language'] = userComment.reviewerLanguage;\n }\n if (userComment.device) {\n attributes['device'] = userComment.device;\n }\n if (userComment.appVersionName) {\n attributes['app_version_name'] = userComment.appVersionName;\n }\n if (typeof userComment.androidOsVersion === 'number') {\n attributes['android_os_version'] = userComment.androidOsVersion;\n }\n\n return {\n name: GPLAY_APP_RATINGS_METRIC,\n ts,\n value: rating,\n attributes,\n };\n}\n\nconst dateOnlyTimeline = z.object({\n startTime: z.object({\n year: z.number().int(),\n month: z.number().int(),\n day: z.number().int(),\n }),\n});\n\nconst metricEntry = z.object({\n metric: z.string(),\n decimalValue: z\n .object({\n value: z.string(),\n })\n .optional(),\n});\n\nfunction metricSetSchema() {\n return z.object({\n rows: z\n .array(\n dateOnlyTimeline.extend({\n metrics: z.array(metricEntry).optional(),\n }),\n )\n .optional(),\n nextPageToken: z.string().optional(),\n });\n}\n\nconst reviewsResponseSchema = z.object({\n reviews: z\n .array(\n z.object({\n reviewId: z.string().optional(),\n authorName: z.string().optional(),\n comments: z\n .array(\n z.object({\n userComment: z\n .object({\n text: z.string().optional(),\n lastModified: z\n .object({\n seconds: z.string().optional(),\n nanos: z.number().optional(),\n })\n .optional(),\n starRating: z.number().int().optional(),\n reviewerLanguage: z.string().optional(),\n device: z.string().optional(),\n androidOsVersion: z.number().int().optional(),\n appVersionCode: z.number().int().optional(),\n appVersionName: z.string().optional(),\n })\n .optional(),\n }),\n )\n .optional(),\n }),\n )\n .optional(),\n tokenPagination: z\n .object({ nextPageToken: z.string().optional() })\n .optional(),\n});\n\nconst installsCsvResponse = z.string();\n\nconst INSTALLS_DATE_DIMENSION = {\n name: 'date',\n description:\n 'Calendar day of the install statistics row, as delivered in the monthly stats/installs CSV.',\n};\n\nconst INSTALLS_PACKAGE_DIMENSION = {\n name: 'package_name',\n description:\n 'Reverse-DNS application id these install statistics are reported against.',\n};\n\nconst INSTALLS_MEASURES = [\n {\n name: 'daily_device_installs',\n description:\n 'Devices that newly installed the app on this day (also the primary metric value).',\n },\n {\n name: 'daily_device_uninstalls',\n description: 'Devices that uninstalled the app on this day.',\n },\n {\n name: 'daily_device_upgrades',\n description: 'Devices that upgraded the app on this day.',\n },\n {\n name: 'current_device_installs',\n description: 'Active devices that have the app installed at end of day.',\n },\n {\n name: 'active_device_installs',\n description:\n 'Installs on active devices (devices active in the trailing 30 days).',\n },\n {\n name: 'current_user_installs',\n description: 'Users that have the app installed at end of day.',\n },\n {\n name: 'total_user_installs',\n description: 'Total users that have ever installed the app.',\n },\n {\n name: 'daily_user_installs',\n description: 'Users that newly installed the app on this day.',\n },\n {\n name: 'daily_user_uninstalls',\n description: 'Users that uninstalled the app on this day.',\n },\n];\n\nfunction installsResource(breakdown: InstallsBreakdown) {\n const dimensions = [INSTALLS_DATE_DIMENSION, INSTALLS_PACKAGE_DIMENSION];\n if (breakdown.dimensionAttr) {\n dimensions.push({\n name: breakdown.dimensionAttr,\n description: breakdown.dimensionDescription,\n });\n }\n return {\n shape: 'metric' as const,\n description: breakdown.description,\n unit: 'installs',\n granularity: 'day',\n endpoint: `GET /storage/v1/b/{installsBucketId}/o/stats%2Finstalls%2Finstalls_{packageName}_{YYYYMM}_${breakdown.fileDimension}.csv`,\n notes:\n 'Sourced from the Play Console monthly stats/installs CSV in Cloud Storage. Files are monthly with daily rows and arrive a few days in arrears; the connector refetches the months overlapping the sync window.',\n dimensions,\n measures: INSTALLS_MEASURES,\n responses: { [breakdown.responseTag]: installsCsvResponse },\n };\n}\n\nexport const googlePlayConsoleResources = defineResources({\n apps: {\n shape: 'entity',\n filterable: [],\n description:\n 'Android app the connector is syncing. One entity per configured packageName, derived from the connector config; the Play Store listing title is only reachable through an Android Publisher edit and is not fetched.',\n fields: [\n {\n name: 'package_name',\n description: 'Reverse-DNS application id (e.g. com.example.app).',\n },\n ],\n },\n gplay_crash_rate_by_day: {\n shape: 'metric',\n description:\n 'Daily crash rate reported by the Play Developer Reporting API. Primary value is the crashRate metric (fraction of distinct users that experienced a crash).',\n unit: 'crashRate',\n granularity: 'day',\n endpoint: 'POST /v1beta1/apps/{packageName}/crashRateMetricSet:query',\n dimensions: [\n {\n name: 'date',\n description:\n 'Calendar day of the metric sample (America/Los_Angeles, the only timezone the Reporting API supports for daily aggregation).',\n },\n {\n name: 'package_name',\n description:\n 'Reverse-DNS application id this sample is reported against.',\n },\n ],\n responses: { crash_rate: metricSetSchema() },\n },\n gplay_anr_rate_by_day: {\n shape: 'metric',\n description:\n 'Daily ANR (Application Not Responding) rate. Primary value is the anrRate metric (fraction of distinct users that experienced an ANR).',\n unit: 'anrRate',\n granularity: 'day',\n endpoint: 'POST /v1beta1/apps/{packageName}/anrRateMetricSet:query',\n dimensions: [\n {\n name: 'date',\n description:\n 'Calendar day of the metric sample (America/Los_Angeles, the only timezone the Reporting API supports for daily aggregation).',\n },\n {\n name: 'package_name',\n description:\n 'Reverse-DNS application id this sample is reported against.',\n },\n ],\n responses: { anr_rate: metricSetSchema() },\n },\n gplay_error_count_by_day: {\n shape: 'metric',\n description:\n 'Daily count of error reports (crashes + ANRs + handled errors) from the Play Developer Reporting API.',\n unit: 'reports',\n granularity: 'day',\n endpoint: 'POST /v1beta1/apps/{packageName}/errorCountMetricSet:query',\n dimensions: [\n {\n name: 'date',\n description:\n 'Calendar day of the metric sample (America/Los_Angeles, the only timezone the Reporting API supports for daily aggregation).',\n },\n {\n name: 'package_name',\n description:\n 'Reverse-DNS application id this sample is reported against.',\n },\n ],\n responses: { errors: metricSetSchema() },\n },\n gplay_app_ratings: {\n shape: 'metric',\n description:\n 'Rolling per-review star ratings sampled from the most-recent user reviews via the Android Publisher reviews API (default 200, configurable via reviewLimit). Each sample carries one review with its star rating (1-5) as the value.',\n unit: 'stars',\n endpoint: 'GET /androidpublisher/v3/applications/{packageName}/reviews',\n notes:\n 'Not the lifetime average shown on the Play Store. The reviews API only returns reviews from roughly the past week, so this is a rolling sample; average over a time window downstream for a smoothed rating.',\n dimensions: [\n {\n name: 'package_name',\n description:\n 'Reverse-DNS application id this review was filed against.',\n },\n { name: 'review_id', description: 'Unique identifier of the review.' },\n {\n name: 'reviewer_language',\n description: 'BCP-47 language code the review was written in.',\n },\n {\n name: 'device',\n description: 'Codename of the device the review was filed from.',\n },\n {\n name: 'app_version_name',\n description: 'App version name the reviewer was running.',\n },\n {\n name: 'android_os_version',\n description: 'Android SDK version the reviewer was running.',\n },\n ],\n responses: { reviews: reviewsResponseSchema },\n },\n gplay_installs_overview_by_day: installsResource(INSTALLS_BREAKDOWNS[0]!),\n gplay_installs_by_country: installsResource(INSTALLS_BREAKDOWNS[1]!),\n gplay_installs_by_app_version: installsResource(INSTALLS_BREAKDOWNS[2]!),\n gplay_installs_by_device: installsResource(INSTALLS_BREAKDOWNS[3]!),\n gplay_installs_by_os_version: installsResource(INSTALLS_BREAKDOWNS[4]!),\n gplay_installs_by_language: installsResource(INSTALLS_BREAKDOWNS[5]!),\n gplay_installs_by_carrier: installsResource(INSTALLS_BREAKDOWNS[6]!),\n});\n\nexport const id = 'google-play-console';\n\nexport class GooglePlayConsoleConnector extends BaseConnector<\n GooglePlayConsoleSettings,\n GplayCredentials\n> {\n static readonly id = id;\n\n static readonly resources = googlePlayConsoleResources;\n\n static readonly schemas = schemasFromResources(googlePlayConsoleResources);\n\n static create(\n input: unknown,\n ctx?: ConnectorContext,\n ): GooglePlayConsoleConnector {\n const parsed = configFields.parse(input);\n let installsBucketId: string | undefined;\n if (parsed.installsBucketId !== undefined) {\n installsBucketId = normalizeInstallsBucketId(parsed.installsBucketId);\n if (installsBucketId.length === 0) {\n throw new Error(\n 'Google Play Console connector: installsBucketId must include a bucket name (e.g. pubsite_prod_rev_...)',\n );\n }\n }\n return new GooglePlayConsoleConnector(\n {\n packageName: parsed.packageName,\n lookbackDays: parsed.lookbackDays,\n reviewLimit: parsed.reviewLimit,\n installsBucketId,\n },\n {\n serviceAccountJson: parsed.serviceAccountJson,\n },\n ctx,\n );\n }\n\n readonly id = id;\n override readonly credentials = gplayCredentials;\n\n private tokenProvider?: GcpAccessTokenProvider;\n\n private getAccessToken(signal?: AbortSignal): Promise<string> {\n this.tokenProvider ??= new GcpAccessTokenProvider({\n connectorId: this.id,\n scope: SCOPES,\n getServiceAccountJson: () => this.creds.serviceAccountJson,\n post: (url, opts) =>\n this.post<{ access_token: string; expires_in?: number }>(url, opts),\n });\n return this.tokenProvider.getToken(signal);\n }\n\n private async runMetricQuery(\n accessToken: string,\n cfg: MetricPhaseConfig,\n dateRange: GplayDateRange,\n pageToken: string | undefined,\n signal?: AbortSignal,\n ): Promise<GplayMetricResponse> {\n const url = `${REPORTING_BASE}/v1beta1/apps/${encodeURIComponent(this.settings.packageName)}/${cfg.metricSet}:query`;\n\n const [sy, sm, sd] = dateRange.startDate.split('-').map(Number) as [\n number,\n number,\n number,\n ];\n const [ey, em, ed] = dateRange.endDate.split('-').map(Number) as [\n number,\n number,\n number,\n ];\n\n const body: Record<string, unknown> = {\n metrics: cfg.metrics,\n timelineSpec: {\n aggregationPeriod: 'DAILY',\n startTime: {\n year: sy,\n month: sm,\n day: sd,\n timeZone: { id: DAILY_TIME_ZONE },\n },\n endTime: {\n year: ey,\n month: em,\n day: ed,\n timeZone: { id: DAILY_TIME_ZONE },\n },\n },\n };\n if (pageToken) {\n body['pageToken'] = pageToken;\n }\n\n const res = await this.post<GplayMetricResponse>(url, {\n resource: cfg.responseTag,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json',\n 'User-Agent': connectorUserAgent('google-play-console'),\n },\n body: JSON.stringify(body),\n signal,\n });\n return res.body;\n }\n\n private async syncApps(storage: StorageHandle): Promise<void> {\n const entity: Entity = {\n type: 'apps',\n id: this.settings.packageName,\n attributes: {\n package_name: this.settings.packageName,\n },\n updated_at: Date.now(),\n };\n await storage.entities([entity], { types: ['apps'] });\n }\n\n private async fetchReviews(\n accessToken: string,\n signal?: AbortSignal,\n ): Promise<GplayReview[]> {\n const reviews: GplayReview[] = [];\n const base = `${PUBLISHER_BASE}/androidpublisher/v3/applications/${encodeURIComponent(this.settings.packageName)}/reviews`;\n let token: string | undefined = undefined;\n for (let page = 0; page < MAX_REVIEW_PAGES; page++) {\n signal?.throwIfAborted();\n const params = new URLSearchParams({\n maxResults: String(REVIEWS_PAGE_SIZE),\n });\n if (token) {\n params.set('token', token);\n }\n const res = await this.get<GplayReviewsResponse>(\n `${base}?${params.toString()}`,\n {\n resource: 'reviews',\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'User-Agent': connectorUserAgent('google-play-console'),\n },\n signal,\n },\n );\n reviews.push(...(res.body.reviews ?? []));\n token = res.body.tokenPagination?.nextPageToken;\n if (!token) {\n return reviews;\n }\n }\n this.logger.warn(\n `Stopped paginating Play Console reviews after ${MAX_REVIEW_PAGES} pages; the most-recent reviews are still ranked first but older reviews may be omitted`,\n { packageName: this.settings.packageName },\n );\n return reviews;\n }\n\n private async syncReviews(\n accessToken: string,\n storage: StorageHandle,\n signal?: AbortSignal,\n ): Promise<void> {\n const limit = this.settings.reviewLimit ?? DEFAULT_REVIEW_LIMIT;\n const reviews = await this.fetchReviews(accessToken, signal);\n const samples = reviews\n .map((review) => reviewToRatingSample(review, this.settings.packageName))\n .filter((s): s is NonNullable<typeof s> => s !== null)\n .sort((a, b) => b.ts - a.ts)\n .slice(0, limit);\n await storage.metrics(samples, { names: [GPLAY_APP_RATINGS_METRIC] });\n }\n\n private async drainMetricPhase(\n accessToken: string,\n cfg: MetricPhaseConfig,\n dateRange: GplayDateRange,\n signal?: AbortSignal,\n ): Promise<GplayMetricRow[]> {\n const rows: GplayMetricRow[] = [];\n let pageToken: string | undefined = undefined;\n for (;;) {\n const res: GplayMetricResponse = await this.runMetricQuery(\n accessToken,\n cfg,\n dateRange,\n pageToken,\n signal,\n );\n if (res.rows) {\n rows.push(...res.rows);\n }\n if (!res.nextPageToken) {\n break;\n }\n pageToken = res.nextPageToken;\n }\n return rows;\n }\n\n private async downloadInstallsCsv(\n accessToken: string,\n bucket: string,\n yyyymm: string,\n breakdown: InstallsBreakdown,\n signal?: AbortSignal,\n ): Promise<string | null> {\n const objectPath = installsObjectPath(\n this.settings.packageName,\n yyyymm,\n breakdown.fileDimension,\n );\n const url = `${GCS_BASE}/storage/v1/b/${encodeURIComponent(bucket)}/o/${encodeURIComponent(objectPath)}?alt=media`;\n try {\n const res = await this.request<Uint8Array>(\n {\n url,\n method: 'GET',\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'User-Agent': connectorUserAgent('google-play-console'),\n },\n parseJson: false,\n binary: true,\n timeoutMs: INSTALLS_DOWNLOAD_TIMEOUT_MS,\n signal,\n },\n { resource: breakdown.responseTag },\n );\n return decodeUtf16Csv(res.body);\n } catch (err) {\n const status = (err as { response?: { status?: number } }).response\n ?.status;\n if (status === 404) {\n return null;\n }\n throw err;\n }\n }\n\n private async syncInstallsBreakdown(\n accessToken: string,\n breakdown: InstallsBreakdown,\n dateRange: GplayDateRange,\n storage: StorageHandle,\n signal?: AbortSignal,\n ): Promise<void> {\n const bucket = this.settings.installsBucketId;\n if (!bucket) {\n return;\n }\n const months = installsMonthsForRange(\n dateRange.startDate,\n dateRange.endDate,\n );\n const samples: InstallsSample[] = [];\n for (const month of months) {\n signal?.throwIfAborted();\n const csv = await this.downloadInstallsCsv(\n accessToken,\n bucket,\n month,\n breakdown,\n signal,\n );\n if (csv === null) {\n continue;\n }\n for (const sample of parseInstallsCsv(\n csv,\n breakdown,\n this.settings.packageName,\n )) {\n const date = sample.attributes['date'];\n if (\n typeof date === 'string' &&\n date >= dateRange.startDate &&\n date <= dateRange.endDate\n ) {\n samples.push(sample);\n }\n }\n }\n await storage.metrics(samples, { names: [breakdown.resource] });\n }\n\n async sync(\n options: SyncOptions,\n storage: StorageHandle,\n signal?: AbortSignal,\n ): Promise<SyncResult> {\n const lookbackDays = this.settings.lookbackDays ?? 30;\n\n const cursor = isGplaySyncCursor(options.cursor)\n ? options.cursor\n : undefined;\n const dateRange = cursor?.dateRange ?? getDateRange(options, lookbackDays);\n const replaceWindow = dateRangeToReplaceWindow(dateRange);\n\n let accessToken: string | null = null;\n const getToken = async (sig?: AbortSignal): Promise<string> => {\n if (!accessToken) {\n accessToken = await this.getAccessToken(sig);\n }\n return accessToken;\n };\n\n const phases = selectActivePhases<string, GplayPhase>(\n (resource) => RESOURCE_TO_PHASE[resource] ?? 'apps',\n PHASE_ORDER,\n options.resources ? [...options.resources] : undefined,\n );\n\n const resumeIdx = cursor ? phases.indexOf(cursor.phase) : -1;\n const startIdx = resumeIdx >= 0 ? resumeIdx : 0;\n\n for (let i = startIdx; i < phases.length; i++) {\n const phase = phases[i]!;\n if (signal?.aborted) {\n return { done: false, cursor: { phase, dateRange } };\n }\n\n try {\n if (phase === 'apps') {\n await this.syncApps(storage);\n continue;\n }\n\n if (phase === 'reviews') {\n const token = await getToken(signal);\n await this.syncReviews(token, storage, signal);\n continue;\n }\n\n const breakdown = INSTALLS_PHASE_TO_BREAKDOWN[phase];\n if (breakdown) {\n if (!this.settings.installsBucketId) {\n this.logger.warn(\n 'Skipping Google Play installs resource because installsBucketId is not configured',\n { resource: breakdown.resource },\n );\n continue;\n }\n const token = await getToken(signal);\n await this.syncInstallsBreakdown(\n token,\n breakdown,\n dateRange,\n storage,\n signal,\n );\n continue;\n }\n\n const cfg = METRIC_PHASE_CONFIGS[phase as MetricPhase];\n const token = await getToken(signal);\n const rows = await this.drainMetricPhase(token, cfg, dateRange, signal);\n const samples = rows\n .map((row) =>\n rowToMetricSample(\n row,\n cfg.metrics,\n cfg.metricName,\n cfg.primaryMetric,\n this.settings.packageName,\n ),\n )\n .filter((s): s is NonNullable<typeof s> => s !== null);\n await storage.metrics(samples, {\n names: [cfg.metricName],\n ...(replaceWindow ? { replaceWindow } : {}),\n });\n } catch (err) {\n if (signal?.aborted) {\n return { done: false, cursor: { phase, dateRange } };\n }\n throw err;\n }\n }\n\n return { done: true };\n }\n}\n","export interface InstallsBreakdown {\n resource: string;\n fileDimension: string;\n phase: string;\n responseTag: string;\n dimensionAttr: string | null;\n dimensionDescription: string;\n description: string;\n}\n\nexport const INSTALLS_BREAKDOWNS: InstallsBreakdown[] = [\n {\n resource: 'gplay_installs_overview_by_day',\n fileDimension: 'overview',\n phase: 'installs_overview',\n responseTag: 'installs_overview',\n dimensionAttr: null,\n dimensionDescription: '',\n description:\n 'Daily install statistics for the app from the Play Console monthly installs report (stats/installs overview CSV). Primary value is Daily Device Installs; uninstalls, upgrades, active-device installs and user-keyed counts are carried as additional attributes.',\n },\n {\n resource: 'gplay_installs_by_country',\n fileDimension: 'country',\n phase: 'installs_country',\n responseTag: 'installs_country',\n dimensionAttr: 'country',\n dimensionDescription:\n 'ISO 3166-1 alpha-2 country/region code the installs are attributed to.',\n description:\n 'Daily install statistics broken down by country/region from the Play Console monthly installs report (stats/installs country CSV).',\n },\n {\n resource: 'gplay_installs_by_app_version',\n fileDimension: 'app_version',\n phase: 'installs_app_version',\n responseTag: 'installs_app_version',\n dimensionAttr: 'app_version_code',\n dimensionDescription: 'Android versionCode the installs are attributed to.',\n description:\n 'Daily install statistics broken down by app version code from the Play Console monthly installs report (stats/installs app_version CSV).',\n },\n {\n resource: 'gplay_installs_by_device',\n fileDimension: 'device',\n phase: 'installs_device',\n responseTag: 'installs_device',\n dimensionAttr: 'device',\n dimensionDescription: 'Device codename the installs are attributed to.',\n description:\n 'Daily install statistics broken down by device from the Play Console monthly installs report (stats/installs device CSV).',\n },\n {\n resource: 'gplay_installs_by_os_version',\n fileDimension: 'os_version',\n phase: 'installs_os_version',\n responseTag: 'installs_os_version',\n dimensionAttr: 'android_os_version',\n dimensionDescription:\n 'Android API level (SDK version) the installs are attributed to.',\n description:\n 'Daily install statistics broken down by Android OS version from the Play Console monthly installs report (stats/installs os_version CSV).',\n },\n {\n resource: 'gplay_installs_by_language',\n fileDimension: 'language',\n phase: 'installs_language',\n responseTag: 'installs_language',\n dimensionAttr: 'language',\n dimensionDescription:\n 'BCP-47 language/locale code the installs are attributed to.',\n description:\n 'Daily install statistics broken down by language from the Play Console monthly installs report (stats/installs language CSV).',\n },\n {\n resource: 'gplay_installs_by_carrier',\n fileDimension: 'carrier',\n phase: 'installs_carrier',\n responseTag: 'installs_carrier',\n dimensionAttr: 'carrier',\n dimensionDescription: 'Mobile carrier the installs are attributed to.',\n description:\n 'Daily install statistics broken down by carrier from the Play Console monthly installs report (stats/installs carrier CSV).',\n },\n];\n\nexport const INSTALLS_METRIC_ATTRIBUTES = [\n 'current_device_installs',\n 'active_device_installs',\n 'daily_device_installs',\n 'daily_device_uninstalls',\n 'daily_device_upgrades',\n 'current_user_installs',\n 'total_user_installs',\n 'daily_user_installs',\n 'daily_user_uninstalls',\n] as const;\n\nconst PRIMARY_METRIC_KEY = 'daily_device_installs';\n\nconst METRIC_KEY_ALIASES: Record<string, string> = {\n installs_on_active_devices: 'active_device_installs',\n};\n\nconst KNOWN_METRIC_KEYS = new Set<string>([\n ...INSTALLS_METRIC_ATTRIBUTES,\n 'installs_on_active_devices',\n]);\n\nconst INSTALLS_DATE_RE = /^\\d{4}-\\d{2}-\\d{2}$/;\n\nexport interface InstallsSample {\n name: string;\n ts: number;\n value: number;\n attributes: Record<string, string | number>;\n}\n\nexport function normalizeInstallsBucketId(value: string): string {\n return value\n .trim()\n .replace(/^gs:\\/\\//i, '')\n .replace(/\\/.*$/, '')\n .replace(/\\/+$/, '');\n}\n\nexport function installsObjectPath(\n packageName: string,\n yyyymm: string,\n fileDimension: string,\n): string {\n return `stats/installs/installs_${packageName}_${yyyymm}_${fileDimension}.csv`;\n}\n\nexport function installsMonthsForRange(\n startDate: string,\n endDate: string,\n): string[] {\n const start = monthIndex(startDate);\n const end = monthIndex(endDate);\n if (start === null || end === null || end < start) {\n return [];\n }\n const months: string[] = [];\n for (let m = start; m <= end; m++) {\n const year = Math.floor(m / 12);\n const month = (m % 12) + 1;\n months.push(\n `${String(year).padStart(4, '0')}${String(month).padStart(2, '0')}`,\n );\n }\n return months;\n}\n\nfunction monthIndex(date: string): number | null {\n if (!INSTALLS_DATE_RE.test(date)) {\n return null;\n }\n const year = Number(date.slice(0, 4));\n const month = Number(date.slice(5, 7));\n return year * 12 + (month - 1);\n}\n\nexport function decodeUtf16Csv(bytes: Uint8Array): string {\n const littleEndian = !(bytes[0] === 0xfe && bytes[1] === 0xff);\n const decoder = new TextDecoder(littleEndian ? 'utf-16le' : 'utf-16be');\n const text = decoder.decode(bytes);\n return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;\n}\n\nexport function parseCsvRows(text: string): string[][] {\n const rows: string[][] = [];\n let field = '';\n let row: string[] = [];\n let inQuotes = false;\n let sawContent = false;\n\n for (let i = 0; i < text.length; i++) {\n const c = text[i]!;\n if (inQuotes) {\n if (c === '\"') {\n if (text[i + 1] === '\"') {\n field += '\"';\n i++;\n } else {\n inQuotes = false;\n }\n } else {\n field += c;\n }\n continue;\n }\n if (c === '\"') {\n inQuotes = true;\n sawContent = true;\n } else if (c === ',') {\n row.push(field);\n field = '';\n sawContent = true;\n } else if (c === '\\n' || c === '\\r') {\n if (c === '\\r' && text[i + 1] === '\\n') {\n i++;\n }\n if (sawContent || field.length > 0 || row.length > 0) {\n row.push(field);\n rows.push(row);\n }\n field = '';\n row = [];\n sawContent = false;\n } else {\n field += c;\n sawContent = true;\n }\n }\n if (sawContent || field.length > 0 || row.length > 0) {\n row.push(field);\n rows.push(row);\n }\n return rows;\n}\n\nfunction normalizeHeaderKey(header: string): string {\n return header\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '_')\n .replace(/^_+|_+$/g, '');\n}\n\nfunction installsDateToMs(date: string): number {\n return Date.UTC(\n Number(date.slice(0, 4)),\n Number(date.slice(5, 7)) - 1,\n Number(date.slice(8, 10)),\n );\n}\n\nexport function parseInstallsCsv(\n text: string,\n breakdown: InstallsBreakdown,\n packageName: string,\n): InstallsSample[] {\n const rows = parseCsvRows(text);\n if (rows.length < 2) {\n return [];\n }\n\n const header = rows[0]!.map(normalizeHeaderKey);\n const dateIdx = header.indexOf('date');\n if (dateIdx < 0) {\n return [];\n }\n\n const metricCols: Array<{ idx: number; key: string }> = [];\n let dimIdx = -1;\n for (let i = 0; i < header.length; i++) {\n const key = header[i]!;\n if (i === dateIdx || key === 'package_name') {\n continue;\n }\n if (KNOWN_METRIC_KEYS.has(key)) {\n metricCols.push({ idx: i, key: METRIC_KEY_ALIASES[key] ?? key });\n } else if (breakdown.dimensionAttr && dimIdx < 0) {\n dimIdx = i;\n }\n }\n if (!metricCols.some((mc) => mc.key === PRIMARY_METRIC_KEY)) {\n return [];\n }\n\n const samples: InstallsSample[] = [];\n for (let r = 1; r < rows.length; r++) {\n const cols = rows[r]!;\n const dateStr = (cols[dateIdx] ?? '').trim();\n if (!INSTALLS_DATE_RE.test(dateStr)) {\n continue;\n }\n\n const attributes: Record<string, string | number> = {\n date: dateStr,\n package_name: packageName,\n };\n if (breakdown.dimensionAttr && dimIdx >= 0) {\n attributes[breakdown.dimensionAttr] = (cols[dimIdx] ?? '').trim();\n }\n for (const mc of metricCols) {\n const raw = (cols[mc.idx] ?? '').trim();\n const parsed = raw === '' ? 0 : Number(raw);\n attributes[mc.key] = Number.isFinite(parsed) ? parsed : 0;\n }\n\n const primary = attributes[PRIMARY_METRIC_KEY];\n const value = typeof primary === 'number' ? primary : 0;\n\n samples.push({\n name: breakdown.resource,\n ts: installsDateToMs(dateStr),\n value,\n attributes,\n });\n }\n\n return samples;\n}\n","import { GooglePlayConsoleConnector } from './google-play-console';\n\nexport {\n configFields,\n doc,\n GooglePlayConsoleConnector,\n googlePlayConsoleResources as resources,\n id,\n rowToMetricSample,\n} from './google-play-console';\nexport type { GooglePlayConsoleSettings } from './google-play-console';\nexport default GooglePlayConsoleConnector;\n"],"mappings":"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","names":["z","z","HTTP_CLIENT_VERSION","DEFAULT_USER_AGENT","z","z","startMs","token"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rawdash/connector-google-play-console",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.2",
|
|
4
4
|
"description": "Rawdash connector for Google Play Console - syncs Play Developer Reporting API daily app vitals (crash rate, ANR rate, ratings, error counts) into the six-shape storage model",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -24,13 +24,14 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"zod": "^4.4.3",
|
|
27
|
-
"@rawdash/core": "0.28.
|
|
27
|
+
"@rawdash/core": "0.28.2"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"fast-check": "^4.8.0",
|
|
31
31
|
"tsup": "^8.0.0",
|
|
32
32
|
"typescript": "^5.7.2",
|
|
33
33
|
"vitest": "^4.1.4",
|
|
34
|
+
"@rawdash/connector-gcp-shared": "0.1.0",
|
|
34
35
|
"@rawdash/connector-shared": "0.3.1",
|
|
35
36
|
"@rawdash/connector-test-utils": "0.0.10"
|
|
36
37
|
},
|