@cross-deck/buckets 0.5.0 → 0.6.0

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/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { S as Sink } from './sink-Cojdm73l.mjs';
2
- export { B as BucketsReport, O as OpCounts, R as ReportSink, a as ReportSinkConfig, b as ResourceCounts } from './sink-Cojdm73l.mjs';
1
+ import { S as Sink } from './sink-DDQmzqm5.mjs';
2
+ export { B as BucketsReport, O as OpCounts, R as ReportSink, a as ReportSinkConfig, b as ResourceCounts } from './sink-DDQmzqm5.mjs';
3
3
 
4
4
  /**
5
5
  * A resource unit — what an adapter counts. Firestore emits `read`/`write`/
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { S as Sink } from './sink-Cojdm73l.js';
2
- export { B as BucketsReport, O as OpCounts, R as ReportSink, a as ReportSinkConfig, b as ResourceCounts } from './sink-Cojdm73l.js';
1
+ import { S as Sink } from './sink-DDQmzqm5.js';
2
+ export { B as BucketsReport, O as OpCounts, R as ReportSink, a as ReportSinkConfig, b as ResourceCounts } from './sink-DDQmzqm5.js';
3
3
 
4
4
  /**
5
5
  * A resource unit — what an adapter counts. Firestore emits `read`/`write`/
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ function bucket(name, fn) {
26
26
  var SEP = "";
27
27
  var labelBuffer = /* @__PURE__ */ new Map();
28
28
  var hourBuffer = /* @__PURE__ */ new Map();
29
+ var minuteBuffer = /* @__PURE__ */ new Map();
29
30
  var sink = null;
30
31
  var flushIntervalMs = 6e4;
31
32
  var onError = null;
@@ -39,6 +40,12 @@ function configureMeter(config) {
39
40
  }
40
41
  var utcDate = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
41
42
  var utcHour = () => (/* @__PURE__ */ new Date()).toISOString().slice(11, 13);
43
+ var utcFiveMin = () => {
44
+ const iso = (/* @__PURE__ */ new Date()).toISOString();
45
+ const hh = iso.slice(11, 13);
46
+ const mm = Math.floor(Number(iso.slice(14, 16)) / 5) * 5;
47
+ return hh + String(mm).padStart(2, "0");
48
+ };
42
49
  function ensureFlushLoop() {
43
50
  if (timer) return;
44
51
  timer = setInterval(() => void flush(), flushIntervalMs);
@@ -56,8 +63,10 @@ function record(resource, quantity, hint) {
56
63
  labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + quantity);
57
64
  const hk = date + SEP + resource + SEP + utcHour();
58
65
  hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + quantity);
66
+ const mk = date + SEP + resource + SEP + utcFiveMin();
67
+ minuteBuffer.set(mk, (minuteBuffer.get(mk) ?? 0) + quantity);
59
68
  ensureFlushLoop();
60
- if (labelBuffer.size + hourBuffer.size > MAX_BUFFER_KEYS) void flush();
69
+ if (labelBuffer.size + hourBuffer.size + minuteBuffer.size > MAX_BUFFER_KEYS) void flush();
61
70
  } catch {
62
71
  }
63
72
  }
@@ -79,20 +88,23 @@ async function flush() {
79
88
  if (!sink) {
80
89
  labelBuffer.clear();
81
90
  hourBuffer.clear();
91
+ minuteBuffer.clear();
82
92
  return;
83
93
  }
84
- if (labelBuffer.size === 0 && hourBuffer.size === 0) return;
94
+ if (labelBuffer.size === 0 && hourBuffer.size === 0 && minuteBuffer.size === 0) return;
85
95
  flushing = true;
86
96
  const labels = new Map(labelBuffer);
87
97
  const hours = new Map(hourBuffer);
98
+ const minutes = new Map(minuteBuffer);
88
99
  labelBuffer.clear();
89
100
  hourBuffer.clear();
101
+ minuteBuffer.clear();
90
102
  try {
91
103
  const byDate = /* @__PURE__ */ new Map();
92
104
  const reportFor = (date) => {
93
105
  let r = byDate.get(date);
94
106
  if (!r) {
95
- r = { date, byLabel: {}, byHour: {} };
107
+ r = { date, byLabel: {}, byHour: {}, byMinute: {} };
96
108
  byDate.set(date, r);
97
109
  }
98
110
  return r;
@@ -105,6 +117,10 @@ async function flush() {
105
117
  const [date, op, hour] = k.split(SEP);
106
118
  add(reportFor(date).byHour, hour, op, n);
107
119
  }
120
+ for (const [k, n] of minutes) {
121
+ const [date, op, slot] = k.split(SEP);
122
+ add(reportFor(date).byMinute, slot, op, n);
123
+ }
108
124
  for (const report of byDate.values()) {
109
125
  await sink.flush(report);
110
126
  }
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/cost-context.ts","../src/cost-meter.ts","../src/sink.ts","../src/adapters/firestore.ts","../src/index.ts"],"names":["AsyncLocalStorage","sink"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * cost-context — the request-scoped tag every counted operation attributes\n * itself to. Set it ONCE at a boundary (or wrap a path with `bucket()`); it\n * rides Node's AsyncLocalStorage down through every async fan-out, so one\n * handler that triggers 15 reads attributes all 15 to the same bucket — with\n * zero per-call-site work.\n *\n * Generic by design: unlike a hardcoded product taxonomy, the only meaningful\n * field a consumer sets is the free-form `label` (the bucket name). `feature`\n * is an optional coarse grouping if you want one; nothing here is\n * Crossdeck-specific.\n */\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\nexport interface CostTag {\n /** Optional coarse grouping (a caller-defined surface name). */\n feature?: string;\n /** The bucket name — what `bucket()` sets. Drives the report's `byLabel`. */\n label?: string;\n}\n\nconst DEFAULT_TAG: CostTag = {};\nconst store = new AsyncLocalStorage<CostTag>();\n\n/** Run `fn` with `tag` bound for its entire async subtree. */\nexport function runWithCostTag<T>(tag: CostTag, fn: () => T): T {\n return store.run({ ...tag }, fn);\n}\n\n/** Bind a tag for the remainder of the current async context (no closure to wrap). */\nexport function enterCostTag(tag: CostTag): void {\n store.enterWith({ ...tag });\n}\n\n/** Refine the live tag in place (e.g. stamp a feature after the boundary). */\nexport function refineCostTag(patch: Partial<CostTag>): void {\n const cur = store.getStore();\n if (cur) Object.assign(cur, patch);\n}\n\n/** The current tag, or a safe empty default outside any bound context. */\nexport function currentCostTag(): CostTag {\n return store.getStore() ?? DEFAULT_TAG;\n}\n\n/**\n * `bucket(name, fn)` — the headline verb, the `track()` of cost. Run `fn` with\n * every operation inside it attributed to the bucket `name`; the attribution\n * rides the async subtree automatically. The one verb most developers ever touch:\n *\n * await bucket(\"nightly-export\", async () => {\n * const rows = await db.collection(\"events\").where(...).get(); // → \"nightly-export\"\n * });\n */\nexport function bucket<T>(name: string, fn: () => T): T {\n return runWithCostTag({ ...currentCostTag(), label: name }, fn);\n}\n","/**\n * cost-meter — counts operations against the ambient tag and flushes them to the\n * configured Sink cheaply.\n *\n * LOW-OVERHEAD CONTRACT (the thing that warns you about reads must not run them\n * up): counts accumulate in an in-memory buffer and flush periodically — NEVER one\n * network call per counted operation. A flush coalesces the whole window into one\n * report per UTC day and hands it to the Sink. At steady state that is ~1 small\n * request a minute, regardless of how many ops you served.\n *\n * BEST-EFFORT CONTRACT: metering must never throw into your code. Every recorder\n * swallows its own errors; a failed flush drops that window's counts (surfaced via\n * `onError` if you pass one) rather than disturbing the app.\n */\nimport { currentCostTag } from \"./cost-context\";\nimport type { Sink, BucketsReport, ResourceCounts } from \"./sink\";\n\n/**\n * A resource unit — what an adapter counts. Firestore emits `read`/`write`/\n * `delete`; other adapters emit their own (`clickhouse.query_ms`, `openai.tokens`).\n * It is a free identifier on purpose, BUT each one is kept entirely distinct: the\n * meter only ever sums quantities WITHIN a single resource, never across two.\n */\nexport type ResourceUnit = string;\n/** @deprecated — Firestore-era name; use {@link ResourceUnit}. */\nexport type OpType = ResourceUnit;\n\n/** Optional read-site hint — the collection touched, derived at the trap from the\n * path. Lets an UNtagged read cascade to `col:<collection>` instead of vanishing. */\nexport interface CostHint {\n collection?: string;\n projectId?: string;\n}\n\n// NUL separator — a bucket/collection name can contain almost anything except\n// this, so the key never collides with a name that has a \"|\" or \":\" in it.\nconst SEP = \"\\u001f\"; // ASCII Unit Separator\n\n/** key = date <NUL> op <NUL> label → count */\nconst labelBuffer = new Map<string, number>();\n/** key = date <NUL> op <NUL> hour → count */\nconst hourBuffer = new Map<string, number>();\n\nlet sink: Sink | null = null;\nlet flushIntervalMs = 60_000;\nlet onError: ((e: unknown) => void) | null = null;\nlet timer: ReturnType<typeof setInterval> | null = null;\nlet flushing = false;\n/** Safety valve — flush early if a burst fills the buffer between intervals. */\nconst MAX_BUFFER_KEYS = 5_000;\n\nexport interface MeterConfig {\n sink: Sink;\n flushIntervalMs?: number;\n onError?: (e: unknown) => void;\n}\n\n/** Point the meter at a sink. Called by `init()`; pass your own sink to self-host. */\nexport function configureMeter(config: MeterConfig): void {\n sink = config.sink;\n if (config.flushIntervalMs && config.flushIntervalMs > 0) flushIntervalMs = config.flushIntervalMs;\n onError = config.onError ?? null;\n}\n\nconst utcDate = (): string => new Date().toISOString().slice(0, 10);\nconst utcHour = (): string => new Date().toISOString().slice(11, 13);\n\nfunction ensureFlushLoop(): void {\n if (timer) return;\n timer = setInterval(() => void flush(), flushIntervalMs);\n // Don't keep the event loop alive just for metering.\n (timer as { unref?: () => void }).unref?.();\n // Flush the last window on shutdown.\n process.once?.(\"SIGTERM\", () => void flush());\n process.once?.(\"beforeExit\", () => void flush());\n}\n\n/**\n * Count `quantity` of a `resource` against the live tag. THE adapter primitive —\n * a Firestore adapter records \"read\"; a ClickHouse adapter records\n * \"clickhouse.query_ms\"; an OpenAI adapter records \"openai.tokens\". Each resource\n * is bucketed entirely on its own; nothing is ever added across resources. Never\n * throws.\n */\nexport function record(resource: ResourceUnit, quantity: number, hint?: CostHint): void {\n try {\n if (!Number.isFinite(quantity) || quantity <= 0) return;\n const t = currentCostTag();\n const date = utcDate();\n // CASCADE — every unit gets a label, by design (no blind spots): the bucket\n // name wins; else the collection it actually touched (`col:posts`); else\n // \"uncategorized\" as a loud last resort. A unit is never invisible.\n const label = t.label || (hint?.collection ? `col:${hint.collection}` : \"uncategorized\");\n // Key includes the resource, so each resource accumulates in its OWN slot.\n const lk = date + SEP + resource + SEP + label;\n labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + quantity);\n const hk = date + SEP + resource + SEP + utcHour();\n hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + quantity);\n ensureFlushLoop();\n if (labelBuffer.size + hourBuffer.size > MAX_BUFFER_KEYS) void flush();\n } catch {\n /* metering is best-effort — never disturb the caller */\n }\n}\n\n/** @deprecated — use {@link record}. Firestore-era alias. */\nexport const recordFirestore = record;\n\n/** Firestore bills a minimum of one read even for an empty result, so 0 counts as 1. */\nexport function recordReads(n: number, hint?: CostHint): void {\n record(\"read\", Math.max(n, 1), hint);\n}\nexport function recordWrites(n = 1): void {\n record(\"write\", n);\n}\nexport function recordDeletes(n = 1): void {\n record(\"delete\", n);\n}\n\nfunction add(target: Record<string, ResourceCounts>, key: string, resource: ResourceUnit, n: number): void {\n const bag = (target[key] ??= {});\n // Accumulate WITHIN this resource only — never merge resources.\n bag[resource] = (bag[resource] ?? 0) + n;\n}\n\n/**\n * Coalesce the buffer into one report per UTC day and hand each to the Sink.\n * Snapshots + clears up front so concurrent records land in the next window.\n * Never throws; a sink failure drops that window (surfaced via `onError`).\n */\nexport async function flush(): Promise<void> {\n if (flushing) return;\n // Not configured (init not called) — drop, don't grow unbounded.\n if (!sink) {\n labelBuffer.clear();\n hourBuffer.clear();\n return;\n }\n if (labelBuffer.size === 0 && hourBuffer.size === 0) return;\n flushing = true;\n\n const labels = new Map(labelBuffer);\n const hours = new Map(hourBuffer);\n labelBuffer.clear();\n hourBuffer.clear();\n\n try {\n const byDate = new Map<string, BucketsReport>();\n const reportFor = (date: string): BucketsReport => {\n let r = byDate.get(date);\n if (!r) {\n r = { date, byLabel: {}, byHour: {} };\n byDate.set(date, r);\n }\n return r;\n };\n for (const [k, n] of labels) {\n const [date, op, label] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byLabel, label, op, n);\n }\n for (const [k, n] of hours) {\n const [date, op, hour] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byHour!, hour, op, n);\n }\n for (const report of byDate.values()) {\n await sink.flush(report);\n }\n } catch (e) {\n // Drop this window rather than risk a partial/double report on retry.\n onError?.(e);\n } finally {\n flushing = false;\n }\n}\n","/**\n * sink — where the meter sends a coalesced rollup, and the wire shape it sends.\n *\n * Abstracting the sink is what makes Buckets storage-agnostic: the meter never\n * knows where counts go. The DEFAULT sink (`ReportSink`) reports up to Crossdeck's\n * ingest endpoint so the numbers surface on your Crossdeck dashboard. A team that\n * wants to self-host can implement `Sink` against anything (Postgres, a file, your\n * own API) without touching the meter.\n */\n\n/**\n * Counts for ONE bucket, keyed by RESOURCE UNIT — the raw quantity of each unit,\n * nothing more (no money; you verify cost on your provider's bill).\n *\n * Firestore, the first adapter, emits `read` / `write` / `delete`. Other adapters\n * emit their own units (`clickhouse.query_ms`, `openai.tokens`, …). The rule that\n * keeps this honest: **each resource is its own line. Counts are only ever summed\n * WITHIN a resource, NEVER across one.** A read is not a query-millisecond; the two\n * never land in the same number. (There is deliberately no \"total units\" field.)\n */\nexport interface ResourceCounts {\n read?: number;\n write?: number;\n delete?: number;\n /** Any other resource unit an adapter emits — kept distinct, never merged. */\n [resource: string]: number | undefined;\n}\n\n/** @deprecated name — kept for back-compat; use {@link ResourceCounts}. */\nexport type OpCounts = ResourceCounts;\n\n/**\n * One coalesced report — the wire contract (see docs/ROLLUP_SCHEMA.md). The meter\n * produces one of these per UTC day in a flush window (usually exactly one).\n */\nexport interface BucketsReport {\n /** UTC day \"YYYY-MM-DD\". */\n date: string;\n /** bucket name → counts. The heart of the report. */\n byLabel: Record<string, OpCounts>;\n /** UTC hour \"HH\" → counts, for the hourly \"did my fix land this hour?\" view. */\n byHour?: Record<string, OpCounts>;\n}\n\n/**\n * A destination for coalesced rollups. `flush` MAY throw on failure — the meter\n * catches it, drops that one window, and never lets it reach your app.\n */\nexport interface Sink {\n flush(report: BucketsReport): Promise<void>;\n}\n\nexport interface ReportSinkConfig {\n /** The project's `cd_sk_` secret key. Server-to-server only. */\n apiKey: string;\n /** Defaults to https://api.cross-deck.com/v1/buckets/report */\n endpoint?: string;\n /** Request timeout (ms); a slow Crossdeck must never stall your flush. */\n timeoutMs?: number;\n}\n\nconst DEFAULT_ENDPOINT = \"https://api.cross-deck.com/v1/buckets/report\";\n\n/**\n * The default sink: POST one coalesced rollup to Crossdeck's ingest endpoint.\n * The ingest folds it into the day's maintained doc with `increment`, so many\n * reports a minute coalesce safely. This path does ZERO database reads — it sends\n * a summary, it does not read. Throws on a non-202 so the meter can log/drop the\n * window; the meter guarantees it never reaches your app.\n */\nexport class ReportSink implements Sink {\n private readonly endpoint: string;\n private readonly apiKey: string;\n private readonly timeoutMs: number;\n\n constructor(config: ReportSinkConfig) {\n this.endpoint = config.endpoint ?? DEFAULT_ENDPOINT;\n this.apiKey = config.apiKey;\n this.timeoutMs = config.timeoutMs ?? 5000;\n }\n\n async flush(report: BucketsReport): Promise<void> {\n const res = await fetch(this.endpoint, {\n method: \"POST\",\n signal: AbortSignal.timeout(this.timeoutMs),\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${this.apiKey}`,\n },\n body: JSON.stringify(report),\n });\n if (res.status !== 202) {\n throw new Error(`Buckets report rejected: HTTP ${res.status}`);\n }\n }\n}\n","/**\n * adapters/firestore — the universal Firestore read meter (the trap).\n *\n * THE LESSON (learned on a real product): per-call-site `recordReads()`\n * instrumentation MISSES paths. You meter the read sites you're looking at and\n * leave the cron / trigger / ingest path uncounted — often the majority of reads,\n * invisible. Humans tag what they see and miss the path that matters.\n *\n * THE FIX: patch the admin SDK's read methods ONCE. From install onward, EVERY\n * read — anywhere, on any code path — is counted under the ambient tag, with zero\n * per-call-site work and no blind spots.\n *\n * SAFETY CONTRACT — this sits on your production read path, so it is defensive by\n * construction. Each wrapper:\n * 1. calls the REAL method first and captures the result,\n * 2. counts in a try/catch that can never throw into the caller,\n * 3. ALWAYS returns the real result, untouched.\n * It cannot break a read, change a result, or add latency beyond one in-memory\n * counter increment. A wrong count is a measurement error, never a correctness or\n * availability one. Idempotent — calling it twice patches once.\n *\n * COUNTING MODEL — a query returning N docs = N reads (an empty result still bills\n * 1, which the meter enforces). A document get = 1. getAll(...) = the ref count.\n * CollectionReference.get IS Query.get (shared prototype method), so patching Query\n * covers collections with no double-count.\n */\nimport { recordReads, type CostHint } from \"../cost-meter\";\n\nlet installed = false;\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\ntype AnyFn = (...args: any[]) => any;\n\n/**\n * The firebase-admin Firestore classes to patch. Pass the module namespace from\n * `firebase-admin/firestore` — only the prototypes present are patched.\n */\nexport interface FirestoreClasses {\n Query?: { prototype: { get?: AnyFn; onSnapshot?: AnyFn } };\n DocumentReference?: { prototype: { get?: AnyFn; onSnapshot?: AnyFn } };\n Transaction?: { prototype: { get?: AnyFn; getAll?: AnyFn } };\n Firestore?: { prototype: { getAll?: AnyFn } };\n /** count() / sum() / average() — aggregation queries bill reads too. */\n AggregateQuery?: { prototype: { get?: AnyFn } };\n}\n\n/** `projects/{id}/…` → the project id, else undefined. Pure string op. */\nfunction projectFromPath(path: string): string | undefined {\n const parts = path.split(\"/\");\n const i = parts.indexOf(\"projects\");\n return i >= 0 && parts[i + 1] ? parts[i + 1] : undefined;\n}\n\n/**\n * Derive { collection, projectId } from the read target's path so an UNtagged read\n * cascades to `col:<collection>` instead of \"uncategorized\". PURE CPU; never reads,\n * never throws. Falls back to firebase-admin's internal `_queryOptions` for filtered\n * queries (which don't expose `.path`).\n */\nfunction hintFrom(target: any): CostHint | undefined {\n try {\n const p = typeof target?.path === \"string\" ? target.path : \"\";\n if (p) {\n const parts = p.split(\"/\").filter(Boolean);\n const collection = parts.length % 2 === 0 ? parts[parts.length - 2] : parts[parts.length - 1];\n return { collection, projectId: projectFromPath(p) };\n }\n const qo = target?._queryOptions;\n if (qo) {\n const collection = typeof qo.collectionId === \"string\" ? qo.collectionId : undefined;\n const parent =\n typeof qo.parentPath?.relativeName === \"string\"\n ? qo.parentPath.relativeName\n : typeof qo.parentPath?.toString === \"function\"\n ? String(qo.parentPath.toString())\n : \"\";\n return { collection, projectId: parent ? projectFromPath(parent) : undefined };\n }\n } catch {\n /* the meter must never throw */\n }\n return undefined;\n}\n\nfunction meterSnap(snap: unknown, hint?: CostHint): void {\n try {\n const size = (snap as { size?: number } | null)?.size;\n recordReads(typeof size === \"number\" ? size : 1, hint);\n } catch {\n /* best-effort */\n }\n}\nfunction meterCount(n: number, hint?: CostHint): void {\n try {\n recordReads(n, hint);\n } catch {\n /* best-effort */\n }\n}\n\n/**\n * Install the universal read meter on the firebase-admin Firestore classes. Call\n * ONCE at process start, before any reads. Pass the namespace from\n * `firebase-admin/firestore` so the exact prototypes the SDK uses are patched:\n *\n * import * as Firestore from \"firebase-admin/firestore\";\n * installFirestoreMeter(Firestore);\n */\nexport function installFirestoreMeter(classes: FirestoreClasses): void {\n if (installed) return;\n installed = true;\n const { Query, DocumentReference, Transaction, Firestore, AggregateQuery } = classes;\n\n // Query.get — covers Query AND CollectionReference (shared prototype method).\n const qGet = Query?.prototype?.get;\n if (qGet) {\n Query!.prototype.get = async function (this: unknown, ...args: any[]) {\n const snap = await qGet.apply(this, args);\n meterSnap(snap, hintFrom(this));\n return snap;\n };\n }\n\n // DocumentReference.get — a single doc = 1 read.\n const dGet = DocumentReference?.prototype?.get;\n if (dGet) {\n DocumentReference!.prototype.get = async function (this: unknown, ...args: any[]) {\n const snap = await dGet.apply(this, args);\n meterCount(1, hintFrom(this));\n return snap;\n };\n }\n\n // Transaction.get — query or doc; size when present, else 1.\n const tGet = Transaction?.prototype?.get;\n if (tGet) {\n Transaction!.prototype.get = async function (this: unknown, ...args: any[]) {\n const res = await tGet.apply(this, args);\n meterSnap(res, hintFrom(args[0]));\n return res;\n };\n }\n\n // Transaction.getAll(...refs) — one read per ref.\n const tGetAll = Transaction?.prototype?.getAll;\n if (tGetAll) {\n Transaction!.prototype.getAll = async function (this: unknown, ...args: any[]) {\n const res = await tGetAll.apply(this, args);\n meterCount(Array.isArray(res) ? res.length : args.length || 1, hintFrom(args[0]));\n return res;\n };\n }\n\n // Firestore.getAll(...refs) — batched doc reads.\n const fGetAll = Firestore?.prototype?.getAll;\n if (fGetAll) {\n Firestore!.prototype.getAll = async function (this: unknown, ...args: any[]) {\n const res = await fGetAll.apply(this, args);\n meterCount(Array.isArray(res) ? res.length : args.length || 1, hintFrom(args[0]));\n return res;\n };\n }\n\n // AggregateQuery.get — count() / sum() / average(). Firestore bills one read per\n // up to 1000 index entries scanned, and the client never sees that entry count —\n // so this is an HONEST ESTIMATE: for count() we derive it from the result\n // (ceil(count / 1000)), else the billed minimum of 1. Observe-only: it reads the\n // result you already got, adds zero reads. (Closes the aggregation blind spot.)\n const aGet = AggregateQuery?.prototype?.get;\n if (aGet) {\n AggregateQuery!.prototype.get = async function (this: unknown, ...args: any[]) {\n const snap = await aGet.apply(this, args);\n try {\n const data = (snap as { data?: () => { count?: number } } | null)?.data?.();\n const count = typeof data?.count === \"number\" ? data.count : 0;\n meterCount(Math.max(1, Math.ceil(count / 1000)), hintFrom(this));\n } catch {\n /* best-effort */\n }\n return snap;\n };\n }\n\n // Query.onSnapshot / DocumentReference.onSnapshot — server-side realtime\n // listeners. We OBSERVE: wrap onNext and count the docs each fire delivers (a\n // query's changed docs — first fire = all matching; a doc = 1). We attach no\n // listener and issue no read; the meter just sees what the listener is already\n // billed. (Closes the server-listener blind spot.)\n const patchOnSnapshot = (proto: { onSnapshot?: AnyFn } | undefined, perDoc: boolean): void => {\n const real = proto?.onSnapshot;\n if (!real) return;\n proto!.onSnapshot = function (this: unknown, ...args: any[]) {\n const hint = hintFrom(this);\n const i = args.findIndex((a) => typeof a === \"function\");\n if (i >= 0) {\n const onNext = args[i];\n args[i] = function (snap: any) {\n try {\n const n = perDoc\n ? 1\n : typeof snap?.docChanges === \"function\"\n ? snap.docChanges().length\n : typeof snap?.size === \"number\"\n ? snap.size\n : 1;\n if (n > 0) meterCount(n, hint);\n } catch {\n /* best-effort */\n }\n return onNext(snap);\n };\n }\n return real.apply(this, args);\n };\n };\n patchOnSnapshot(Query?.prototype, false);\n patchOnSnapshot(DocumentReference?.prototype, true);\n}\n\n/** Test-only: reset the install guard so a suite can re-patch fresh prototypes. */\nexport function __resetFirestoreMeterForTests(): void {\n installed = false;\n}\n","/**\n * @cross-deck/buckets — know exactly what every database read costs you, and who\n * caused it. A tiny, never-throws collector for Firestore.\n *\n * The whole footprint a consumer sees:\n * 1. init({ apiKey, firestore }) — configure once, install the trap once\n * 2. bucket(name, fn) — name the read paths that matter\n * 3. (the dashboard shows the rest — and names the ones you haven't yet)\n */\nimport { configureMeter, type MeterConfig } from \"./cost-meter\";\nimport { ReportSink, type Sink } from \"./sink\";\nimport { installFirestoreMeter, type FirestoreClasses } from \"./adapters/firestore\";\n\nexport interface InitOptions {\n /** The project's `cd_sk_` SECRET key. Server-to-server only — never a browser key. */\n apiKey: string;\n /**\n * Pass the namespace from `firebase-admin/firestore` to auto-install the read\n * trap (recommended — this is what makes every read count with no per-call work).\n * Omit it if you'd rather call `installFirestoreMeter()` yourself, or you only\n * use the manual `recordReads()` recorders.\n */\n firestore?: FirestoreClasses;\n /** Override the report endpoint (defaults to Crossdeck's ingest). */\n endpoint?: string;\n /** How often to flush coalesced counts (ms). Default 60_000. */\n flushIntervalMs?: number;\n /** Bring your own sink (self-host the rollups). Defaults to reporting up to Crossdeck. */\n sink?: Sink;\n /** Notified when a flush fails, so a dropped window is never silent. Best-effort. */\n onError?: MeterConfig[\"onError\"];\n}\n\n/**\n * Configure Buckets once, at process start. Points the meter at a sink (Crossdeck's\n * ingest by default) and — if you pass `firestore` — installs the universal read\n * trap so every read counts automatically.\n */\nexport function init(options: InitOptions): void {\n const sink = options.sink ?? new ReportSink({ apiKey: options.apiKey, endpoint: options.endpoint });\n configureMeter({ sink, flushIntervalMs: options.flushIntervalMs, onError: options.onError });\n if (options.firestore) installFirestoreMeter(options.firestore);\n}\n\n/** Alias — reads well next to `bucket()` at a call site. */\nexport { init as initBuckets };\n\n// The headline verb + the lower-level tag controls it is sugar over.\nexport {\n bucket,\n runWithCostTag,\n enterCostTag,\n refineCostTag,\n currentCostTag,\n type CostTag,\n} from \"./cost-context\";\n\n// Recorders. `record(resource, quantity)` is the generic adapter primitive — count\n// any resource unit (a future adapter records \"clickhouse.query_ms\"); recordReads/\n// Writes/Deletes are the Firestore conveniences over it.\nexport {\n record,\n recordReads,\n recordWrites,\n recordDeletes,\n flush,\n type CostHint,\n type ResourceUnit,\n type OpType,\n type MeterConfig,\n} from \"./cost-meter\";\n\n// The trap (the only datastore adapter today) + its class shape.\nexport { installFirestoreMeter, type FirestoreClasses } from \"./adapters/firestore\";\n\n// The sink seam — for self-hosting rollups instead of reporting to Crossdeck.\nexport {\n ReportSink,\n type Sink,\n type BucketsReport,\n type ResourceCounts,\n type OpCounts,\n type ReportSinkConfig,\n} from \"./sink\";\n"]}
1
+ {"version":3,"sources":["../src/cost-context.ts","../src/cost-meter.ts","../src/sink.ts","../src/adapters/firestore.ts","../src/index.ts"],"names":["AsyncLocalStorage","sink"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * cost-context — the request-scoped tag every counted operation attributes\n * itself to. Set it ONCE at a boundary (or wrap a path with `bucket()`); it\n * rides Node's AsyncLocalStorage down through every async fan-out, so one\n * handler that triggers 15 reads attributes all 15 to the same bucket — with\n * zero per-call-site work.\n *\n * Generic by design: unlike a hardcoded product taxonomy, the only meaningful\n * field a consumer sets is the free-form `label` (the bucket name). `feature`\n * is an optional coarse grouping if you want one; nothing here is\n * Crossdeck-specific.\n */\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\nexport interface CostTag {\n /** Optional coarse grouping (a caller-defined surface name). */\n feature?: string;\n /** The bucket name — what `bucket()` sets. Drives the report's `byLabel`. */\n label?: string;\n}\n\nconst DEFAULT_TAG: CostTag = {};\nconst store = new AsyncLocalStorage<CostTag>();\n\n/** Run `fn` with `tag` bound for its entire async subtree. */\nexport function runWithCostTag<T>(tag: CostTag, fn: () => T): T {\n return store.run({ ...tag }, fn);\n}\n\n/** Bind a tag for the remainder of the current async context (no closure to wrap). */\nexport function enterCostTag(tag: CostTag): void {\n store.enterWith({ ...tag });\n}\n\n/** Refine the live tag in place (e.g. stamp a feature after the boundary). */\nexport function refineCostTag(patch: Partial<CostTag>): void {\n const cur = store.getStore();\n if (cur) Object.assign(cur, patch);\n}\n\n/** The current tag, or a safe empty default outside any bound context. */\nexport function currentCostTag(): CostTag {\n return store.getStore() ?? DEFAULT_TAG;\n}\n\n/**\n * `bucket(name, fn)` — the headline verb, the `track()` of cost. Run `fn` with\n * every operation inside it attributed to the bucket `name`; the attribution\n * rides the async subtree automatically. The one verb most developers ever touch:\n *\n * await bucket(\"nightly-export\", async () => {\n * const rows = await db.collection(\"events\").where(...).get(); // → \"nightly-export\"\n * });\n */\nexport function bucket<T>(name: string, fn: () => T): T {\n return runWithCostTag({ ...currentCostTag(), label: name }, fn);\n}\n","/**\n * cost-meter — counts operations against the ambient tag and flushes them to the\n * configured Sink cheaply.\n *\n * LOW-OVERHEAD CONTRACT (the thing that warns you about reads must not run them\n * up): counts accumulate in an in-memory buffer and flush periodically — NEVER one\n * network call per counted operation. A flush coalesces the whole window into one\n * report per UTC day and hands it to the Sink. At steady state that is ~1 small\n * request a minute, regardless of how many ops you served.\n *\n * BEST-EFFORT CONTRACT: metering must never throw into your code. Every recorder\n * swallows its own errors; a failed flush drops that window's counts (surfaced via\n * `onError` if you pass one) rather than disturbing the app.\n */\nimport { currentCostTag } from \"./cost-context\";\nimport type { Sink, BucketsReport, ResourceCounts } from \"./sink\";\n\n/**\n * A resource unit — what an adapter counts. Firestore emits `read`/`write`/\n * `delete`; other adapters emit their own (`clickhouse.query_ms`, `openai.tokens`).\n * It is a free identifier on purpose, BUT each one is kept entirely distinct: the\n * meter only ever sums quantities WITHIN a single resource, never across two.\n */\nexport type ResourceUnit = string;\n/** @deprecated — Firestore-era name; use {@link ResourceUnit}. */\nexport type OpType = ResourceUnit;\n\n/** Optional read-site hint — the collection touched, derived at the trap from the\n * path. Lets an UNtagged read cascade to `col:<collection>` instead of vanishing. */\nexport interface CostHint {\n collection?: string;\n projectId?: string;\n}\n\n// NUL separator — a bucket/collection name can contain almost anything except\n// this, so the key never collides with a name that has a \"|\" or \":\" in it.\nconst SEP = \"\\u001f\"; // ASCII Unit Separator\n\n/** key = date <NUL> resource <NUL> label → count */\nconst labelBuffer = new Map<string, number>();\n/** key = date <NUL> resource <NUL> hour → count */\nconst hourBuffer = new Map<string, number>();\n/** key = date <NUL> resource <NUL> 5-min-slot (\"HHMM\") → count. The fine grain\n * that lets a developer verify against the provider console's \"last hour\" within\n * minutes of installing — not after a day. Still one maintained doc, zero reads. */\nconst minuteBuffer = new Map<string, number>();\n\nlet sink: Sink | null = null;\nlet flushIntervalMs = 60_000;\nlet onError: ((e: unknown) => void) | null = null;\nlet timer: ReturnType<typeof setInterval> | null = null;\nlet flushing = false;\n/** Safety valve — flush early if a burst fills the buffer between intervals. */\nconst MAX_BUFFER_KEYS = 5_000;\n\nexport interface MeterConfig {\n sink: Sink;\n flushIntervalMs?: number;\n onError?: (e: unknown) => void;\n}\n\n/** Point the meter at a sink. Called by `init()`; pass your own sink to self-host. */\nexport function configureMeter(config: MeterConfig): void {\n sink = config.sink;\n if (config.flushIntervalMs && config.flushIntervalMs > 0) flushIntervalMs = config.flushIntervalMs;\n onError = config.onError ?? null;\n}\n\nconst utcDate = (): string => new Date().toISOString().slice(0, 10);\nconst utcHour = (): string => new Date().toISOString().slice(11, 13);\n/** UTC 5-minute slot of the day as \"HHMM\" — the slot START (e.g. 08:47 → \"0845\"). */\nconst utcFiveMin = (): string => {\n const iso = new Date().toISOString();\n const hh = iso.slice(11, 13);\n const mm = Math.floor(Number(iso.slice(14, 16)) / 5) * 5;\n return hh + String(mm).padStart(2, \"0\");\n};\n\nfunction ensureFlushLoop(): void {\n if (timer) return;\n timer = setInterval(() => void flush(), flushIntervalMs);\n // Don't keep the event loop alive just for metering.\n (timer as { unref?: () => void }).unref?.();\n // Flush the last window on shutdown.\n process.once?.(\"SIGTERM\", () => void flush());\n process.once?.(\"beforeExit\", () => void flush());\n}\n\n/**\n * Count `quantity` of a `resource` against the live tag. THE adapter primitive —\n * a Firestore adapter records \"read\"; a ClickHouse adapter records\n * \"clickhouse.query_ms\"; an OpenAI adapter records \"openai.tokens\". Each resource\n * is bucketed entirely on its own; nothing is ever added across resources. Never\n * throws.\n */\nexport function record(resource: ResourceUnit, quantity: number, hint?: CostHint): void {\n try {\n if (!Number.isFinite(quantity) || quantity <= 0) return;\n const t = currentCostTag();\n const date = utcDate();\n // CASCADE — every unit gets a label, by design (no blind spots): the bucket\n // name wins; else the collection it actually touched (`col:posts`); else\n // \"uncategorized\" as a loud last resort. A unit is never invisible.\n const label = t.label || (hint?.collection ? `col:${hint.collection}` : \"uncategorized\");\n // Key includes the resource, so each resource accumulates in its OWN slot.\n const lk = date + SEP + resource + SEP + label;\n labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + quantity);\n const hk = date + SEP + resource + SEP + utcHour();\n hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + quantity);\n const mk = date + SEP + resource + SEP + utcFiveMin();\n minuteBuffer.set(mk, (minuteBuffer.get(mk) ?? 0) + quantity);\n ensureFlushLoop();\n if (labelBuffer.size + hourBuffer.size + minuteBuffer.size > MAX_BUFFER_KEYS) void flush();\n } catch {\n /* metering is best-effort — never disturb the caller */\n }\n}\n\n/** @deprecated — use {@link record}. Firestore-era alias. */\nexport const recordFirestore = record;\n\n/** Firestore bills a minimum of one read even for an empty result, so 0 counts as 1. */\nexport function recordReads(n: number, hint?: CostHint): void {\n record(\"read\", Math.max(n, 1), hint);\n}\nexport function recordWrites(n = 1): void {\n record(\"write\", n);\n}\nexport function recordDeletes(n = 1): void {\n record(\"delete\", n);\n}\n\nfunction add(target: Record<string, ResourceCounts>, key: string, resource: ResourceUnit, n: number): void {\n const bag = (target[key] ??= {});\n // Accumulate WITHIN this resource only — never merge resources.\n bag[resource] = (bag[resource] ?? 0) + n;\n}\n\n/**\n * Coalesce the buffer into one report per UTC day and hand each to the Sink.\n * Snapshots + clears up front so concurrent records land in the next window.\n * Never throws; a sink failure drops that window (surfaced via `onError`).\n */\nexport async function flush(): Promise<void> {\n if (flushing) return;\n // Not configured (init not called) — drop, don't grow unbounded.\n if (!sink) {\n labelBuffer.clear();\n hourBuffer.clear();\n minuteBuffer.clear();\n return;\n }\n if (labelBuffer.size === 0 && hourBuffer.size === 0 && minuteBuffer.size === 0) return;\n flushing = true;\n\n const labels = new Map(labelBuffer);\n const hours = new Map(hourBuffer);\n const minutes = new Map(minuteBuffer);\n labelBuffer.clear();\n hourBuffer.clear();\n minuteBuffer.clear();\n\n try {\n const byDate = new Map<string, BucketsReport>();\n const reportFor = (date: string): BucketsReport => {\n let r = byDate.get(date);\n if (!r) {\n r = { date, byLabel: {}, byHour: {}, byMinute: {} };\n byDate.set(date, r);\n }\n return r;\n };\n for (const [k, n] of labels) {\n const [date, op, label] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byLabel, label, op, n);\n }\n for (const [k, n] of hours) {\n const [date, op, hour] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byHour!, hour, op, n);\n }\n for (const [k, n] of minutes) {\n const [date, op, slot] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byMinute!, slot, op, n);\n }\n for (const report of byDate.values()) {\n await sink.flush(report);\n }\n } catch (e) {\n // Drop this window rather than risk a partial/double report on retry.\n onError?.(e);\n } finally {\n flushing = false;\n }\n}\n","/**\n * sink — where the meter sends a coalesced rollup, and the wire shape it sends.\n *\n * Abstracting the sink is what makes Buckets storage-agnostic: the meter never\n * knows where counts go. The DEFAULT sink (`ReportSink`) reports up to Crossdeck's\n * ingest endpoint so the numbers surface on your Crossdeck dashboard. A team that\n * wants to self-host can implement `Sink` against anything (Postgres, a file, your\n * own API) without touching the meter.\n */\n\n/**\n * Counts for ONE bucket, keyed by RESOURCE UNIT — the raw quantity of each unit,\n * nothing more (no money; you verify cost on your provider's bill).\n *\n * Firestore, the first adapter, emits `read` / `write` / `delete`. Other adapters\n * emit their own units (`clickhouse.query_ms`, `openai.tokens`, …). The rule that\n * keeps this honest: **each resource is its own line. Counts are only ever summed\n * WITHIN a resource, NEVER across one.** A read is not a query-millisecond; the two\n * never land in the same number. (There is deliberately no \"total units\" field.)\n */\nexport interface ResourceCounts {\n read?: number;\n write?: number;\n delete?: number;\n /** Any other resource unit an adapter emits — kept distinct, never merged. */\n [resource: string]: number | undefined;\n}\n\n/** @deprecated name — kept for back-compat; use {@link ResourceCounts}. */\nexport type OpCounts = ResourceCounts;\n\n/**\n * One coalesced report — the wire contract (see docs/ROLLUP_SCHEMA.md). The meter\n * produces one of these per UTC day in a flush window (usually exactly one).\n */\nexport interface BucketsReport {\n /** UTC day \"YYYY-MM-DD\". */\n date: string;\n /** bucket name → counts. The heart of the report. */\n byLabel: Record<string, ResourceCounts>;\n /** UTC hour \"HH\" → counts, for the hourly \"did my fix land this hour?\" view. */\n byHour?: Record<string, ResourceCounts>;\n /** UTC 5-minute slot \"HHMM\" (slot start) → counts. The fine grain for fast\n * verification against a provider console's \"last hour\" view. */\n byMinute?: Record<string, ResourceCounts>;\n}\n\n/**\n * A destination for coalesced rollups. `flush` MAY throw on failure — the meter\n * catches it, drops that one window, and never lets it reach your app.\n */\nexport interface Sink {\n flush(report: BucketsReport): Promise<void>;\n}\n\nexport interface ReportSinkConfig {\n /** The project's `cd_sk_` secret key. Server-to-server only. */\n apiKey: string;\n /** Defaults to https://api.cross-deck.com/v1/buckets/report */\n endpoint?: string;\n /** Request timeout (ms); a slow Crossdeck must never stall your flush. */\n timeoutMs?: number;\n}\n\nconst DEFAULT_ENDPOINT = \"https://api.cross-deck.com/v1/buckets/report\";\n\n/**\n * The default sink: POST one coalesced rollup to Crossdeck's ingest endpoint.\n * The ingest folds it into the day's maintained doc with `increment`, so many\n * reports a minute coalesce safely. This path does ZERO database reads — it sends\n * a summary, it does not read. Throws on a non-202 so the meter can log/drop the\n * window; the meter guarantees it never reaches your app.\n */\nexport class ReportSink implements Sink {\n private readonly endpoint: string;\n private readonly apiKey: string;\n private readonly timeoutMs: number;\n\n constructor(config: ReportSinkConfig) {\n this.endpoint = config.endpoint ?? DEFAULT_ENDPOINT;\n this.apiKey = config.apiKey;\n this.timeoutMs = config.timeoutMs ?? 5000;\n }\n\n async flush(report: BucketsReport): Promise<void> {\n const res = await fetch(this.endpoint, {\n method: \"POST\",\n signal: AbortSignal.timeout(this.timeoutMs),\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${this.apiKey}`,\n },\n body: JSON.stringify(report),\n });\n if (res.status !== 202) {\n throw new Error(`Buckets report rejected: HTTP ${res.status}`);\n }\n }\n}\n","/**\n * adapters/firestore — the universal Firestore read meter (the trap).\n *\n * THE LESSON (learned on a real product): per-call-site `recordReads()`\n * instrumentation MISSES paths. You meter the read sites you're looking at and\n * leave the cron / trigger / ingest path uncounted — often the majority of reads,\n * invisible. Humans tag what they see and miss the path that matters.\n *\n * THE FIX: patch the admin SDK's read methods ONCE. From install onward, EVERY\n * read — anywhere, on any code path — is counted under the ambient tag, with zero\n * per-call-site work and no blind spots.\n *\n * SAFETY CONTRACT — this sits on your production read path, so it is defensive by\n * construction. Each wrapper:\n * 1. calls the REAL method first and captures the result,\n * 2. counts in a try/catch that can never throw into the caller,\n * 3. ALWAYS returns the real result, untouched.\n * It cannot break a read, change a result, or add latency beyond one in-memory\n * counter increment. A wrong count is a measurement error, never a correctness or\n * availability one. Idempotent — calling it twice patches once.\n *\n * COUNTING MODEL — a query returning N docs = N reads (an empty result still bills\n * 1, which the meter enforces). A document get = 1. getAll(...) = the ref count.\n * CollectionReference.get IS Query.get (shared prototype method), so patching Query\n * covers collections with no double-count.\n */\nimport { recordReads, type CostHint } from \"../cost-meter\";\n\nlet installed = false;\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\ntype AnyFn = (...args: any[]) => any;\n\n/**\n * The firebase-admin Firestore classes to patch. Pass the module namespace from\n * `firebase-admin/firestore` — only the prototypes present are patched.\n */\nexport interface FirestoreClasses {\n Query?: { prototype: { get?: AnyFn; onSnapshot?: AnyFn } };\n DocumentReference?: { prototype: { get?: AnyFn; onSnapshot?: AnyFn } };\n Transaction?: { prototype: { get?: AnyFn; getAll?: AnyFn } };\n Firestore?: { prototype: { getAll?: AnyFn } };\n /** count() / sum() / average() — aggregation queries bill reads too. */\n AggregateQuery?: { prototype: { get?: AnyFn } };\n}\n\n/** `projects/{id}/…` → the project id, else undefined. Pure string op. */\nfunction projectFromPath(path: string): string | undefined {\n const parts = path.split(\"/\");\n const i = parts.indexOf(\"projects\");\n return i >= 0 && parts[i + 1] ? parts[i + 1] : undefined;\n}\n\n/**\n * Derive { collection, projectId } from the read target's path so an UNtagged read\n * cascades to `col:<collection>` instead of \"uncategorized\". PURE CPU; never reads,\n * never throws. Falls back to firebase-admin's internal `_queryOptions` for filtered\n * queries (which don't expose `.path`).\n */\nfunction hintFrom(target: any): CostHint | undefined {\n try {\n const p = typeof target?.path === \"string\" ? target.path : \"\";\n if (p) {\n const parts = p.split(\"/\").filter(Boolean);\n const collection = parts.length % 2 === 0 ? parts[parts.length - 2] : parts[parts.length - 1];\n return { collection, projectId: projectFromPath(p) };\n }\n const qo = target?._queryOptions;\n if (qo) {\n const collection = typeof qo.collectionId === \"string\" ? qo.collectionId : undefined;\n const parent =\n typeof qo.parentPath?.relativeName === \"string\"\n ? qo.parentPath.relativeName\n : typeof qo.parentPath?.toString === \"function\"\n ? String(qo.parentPath.toString())\n : \"\";\n return { collection, projectId: parent ? projectFromPath(parent) : undefined };\n }\n } catch {\n /* the meter must never throw */\n }\n return undefined;\n}\n\nfunction meterSnap(snap: unknown, hint?: CostHint): void {\n try {\n const size = (snap as { size?: number } | null)?.size;\n recordReads(typeof size === \"number\" ? size : 1, hint);\n } catch {\n /* best-effort */\n }\n}\nfunction meterCount(n: number, hint?: CostHint): void {\n try {\n recordReads(n, hint);\n } catch {\n /* best-effort */\n }\n}\n\n/**\n * Install the universal read meter on the firebase-admin Firestore classes. Call\n * ONCE at process start, before any reads. Pass the namespace from\n * `firebase-admin/firestore` so the exact prototypes the SDK uses are patched:\n *\n * import * as Firestore from \"firebase-admin/firestore\";\n * installFirestoreMeter(Firestore);\n */\nexport function installFirestoreMeter(classes: FirestoreClasses): void {\n if (installed) return;\n installed = true;\n const { Query, DocumentReference, Transaction, Firestore, AggregateQuery } = classes;\n\n // Query.get — covers Query AND CollectionReference (shared prototype method).\n const qGet = Query?.prototype?.get;\n if (qGet) {\n Query!.prototype.get = async function (this: unknown, ...args: any[]) {\n const snap = await qGet.apply(this, args);\n meterSnap(snap, hintFrom(this));\n return snap;\n };\n }\n\n // DocumentReference.get — a single doc = 1 read.\n const dGet = DocumentReference?.prototype?.get;\n if (dGet) {\n DocumentReference!.prototype.get = async function (this: unknown, ...args: any[]) {\n const snap = await dGet.apply(this, args);\n meterCount(1, hintFrom(this));\n return snap;\n };\n }\n\n // Transaction.get — query or doc; size when present, else 1.\n const tGet = Transaction?.prototype?.get;\n if (tGet) {\n Transaction!.prototype.get = async function (this: unknown, ...args: any[]) {\n const res = await tGet.apply(this, args);\n meterSnap(res, hintFrom(args[0]));\n return res;\n };\n }\n\n // Transaction.getAll(...refs) — one read per ref.\n const tGetAll = Transaction?.prototype?.getAll;\n if (tGetAll) {\n Transaction!.prototype.getAll = async function (this: unknown, ...args: any[]) {\n const res = await tGetAll.apply(this, args);\n meterCount(Array.isArray(res) ? res.length : args.length || 1, hintFrom(args[0]));\n return res;\n };\n }\n\n // Firestore.getAll(...refs) — batched doc reads.\n const fGetAll = Firestore?.prototype?.getAll;\n if (fGetAll) {\n Firestore!.prototype.getAll = async function (this: unknown, ...args: any[]) {\n const res = await fGetAll.apply(this, args);\n meterCount(Array.isArray(res) ? res.length : args.length || 1, hintFrom(args[0]));\n return res;\n };\n }\n\n // AggregateQuery.get — count() / sum() / average(). Firestore bills one read per\n // up to 1000 index entries scanned, and the client never sees that entry count —\n // so this is an HONEST ESTIMATE: for count() we derive it from the result\n // (ceil(count / 1000)), else the billed minimum of 1. Observe-only: it reads the\n // result you already got, adds zero reads. (Closes the aggregation blind spot.)\n const aGet = AggregateQuery?.prototype?.get;\n if (aGet) {\n AggregateQuery!.prototype.get = async function (this: unknown, ...args: any[]) {\n const snap = await aGet.apply(this, args);\n try {\n const data = (snap as { data?: () => { count?: number } } | null)?.data?.();\n const count = typeof data?.count === \"number\" ? data.count : 0;\n meterCount(Math.max(1, Math.ceil(count / 1000)), hintFrom(this));\n } catch {\n /* best-effort */\n }\n return snap;\n };\n }\n\n // Query.onSnapshot / DocumentReference.onSnapshot — server-side realtime\n // listeners. We OBSERVE: wrap onNext and count the docs each fire delivers (a\n // query's changed docs — first fire = all matching; a doc = 1). We attach no\n // listener and issue no read; the meter just sees what the listener is already\n // billed. (Closes the server-listener blind spot.)\n const patchOnSnapshot = (proto: { onSnapshot?: AnyFn } | undefined, perDoc: boolean): void => {\n const real = proto?.onSnapshot;\n if (!real) return;\n proto!.onSnapshot = function (this: unknown, ...args: any[]) {\n const hint = hintFrom(this);\n const i = args.findIndex((a) => typeof a === \"function\");\n if (i >= 0) {\n const onNext = args[i];\n args[i] = function (snap: any) {\n try {\n const n = perDoc\n ? 1\n : typeof snap?.docChanges === \"function\"\n ? snap.docChanges().length\n : typeof snap?.size === \"number\"\n ? snap.size\n : 1;\n if (n > 0) meterCount(n, hint);\n } catch {\n /* best-effort */\n }\n return onNext(snap);\n };\n }\n return real.apply(this, args);\n };\n };\n patchOnSnapshot(Query?.prototype, false);\n patchOnSnapshot(DocumentReference?.prototype, true);\n}\n\n/** Test-only: reset the install guard so a suite can re-patch fresh prototypes. */\nexport function __resetFirestoreMeterForTests(): void {\n installed = false;\n}\n","/**\n * @cross-deck/buckets — know exactly what every database read costs you, and who\n * caused it. A tiny, never-throws collector for Firestore.\n *\n * The whole footprint a consumer sees:\n * 1. init({ apiKey, firestore }) — configure once, install the trap once\n * 2. bucket(name, fn) — name the read paths that matter\n * 3. (the dashboard shows the rest — and names the ones you haven't yet)\n */\nimport { configureMeter, type MeterConfig } from \"./cost-meter\";\nimport { ReportSink, type Sink } from \"./sink\";\nimport { installFirestoreMeter, type FirestoreClasses } from \"./adapters/firestore\";\n\nexport interface InitOptions {\n /** The project's `cd_sk_` SECRET key. Server-to-server only — never a browser key. */\n apiKey: string;\n /**\n * Pass the namespace from `firebase-admin/firestore` to auto-install the read\n * trap (recommended — this is what makes every read count with no per-call work).\n * Omit it if you'd rather call `installFirestoreMeter()` yourself, or you only\n * use the manual `recordReads()` recorders.\n */\n firestore?: FirestoreClasses;\n /** Override the report endpoint (defaults to Crossdeck's ingest). */\n endpoint?: string;\n /** How often to flush coalesced counts (ms). Default 60_000. */\n flushIntervalMs?: number;\n /** Bring your own sink (self-host the rollups). Defaults to reporting up to Crossdeck. */\n sink?: Sink;\n /** Notified when a flush fails, so a dropped window is never silent. Best-effort. */\n onError?: MeterConfig[\"onError\"];\n}\n\n/**\n * Configure Buckets once, at process start. Points the meter at a sink (Crossdeck's\n * ingest by default) and — if you pass `firestore` — installs the universal read\n * trap so every read counts automatically.\n */\nexport function init(options: InitOptions): void {\n const sink = options.sink ?? new ReportSink({ apiKey: options.apiKey, endpoint: options.endpoint });\n configureMeter({ sink, flushIntervalMs: options.flushIntervalMs, onError: options.onError });\n if (options.firestore) installFirestoreMeter(options.firestore);\n}\n\n/** Alias — reads well next to `bucket()` at a call site. */\nexport { init as initBuckets };\n\n// The headline verb + the lower-level tag controls it is sugar over.\nexport {\n bucket,\n runWithCostTag,\n enterCostTag,\n refineCostTag,\n currentCostTag,\n type CostTag,\n} from \"./cost-context\";\n\n// Recorders. `record(resource, quantity)` is the generic adapter primitive — count\n// any resource unit (a future adapter records \"clickhouse.query_ms\"); recordReads/\n// Writes/Deletes are the Firestore conveniences over it.\nexport {\n record,\n recordReads,\n recordWrites,\n recordDeletes,\n flush,\n type CostHint,\n type ResourceUnit,\n type OpType,\n type MeterConfig,\n} from \"./cost-meter\";\n\n// The trap (the only datastore adapter today) + its class shape.\nexport { installFirestoreMeter, type FirestoreClasses } from \"./adapters/firestore\";\n\n// The sink seam — for self-hosting rollups instead of reporting to Crossdeck.\nexport {\n ReportSink,\n type Sink,\n type BucketsReport,\n type ResourceCounts,\n type OpCounts,\n type ReportSinkConfig,\n} from \"./sink\";\n"]}
package/dist/index.mjs CHANGED
@@ -24,6 +24,7 @@ function bucket(name, fn) {
24
24
  var SEP = "";
25
25
  var labelBuffer = /* @__PURE__ */ new Map();
26
26
  var hourBuffer = /* @__PURE__ */ new Map();
27
+ var minuteBuffer = /* @__PURE__ */ new Map();
27
28
  var sink = null;
28
29
  var flushIntervalMs = 6e4;
29
30
  var onError = null;
@@ -37,6 +38,12 @@ function configureMeter(config) {
37
38
  }
38
39
  var utcDate = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
39
40
  var utcHour = () => (/* @__PURE__ */ new Date()).toISOString().slice(11, 13);
41
+ var utcFiveMin = () => {
42
+ const iso = (/* @__PURE__ */ new Date()).toISOString();
43
+ const hh = iso.slice(11, 13);
44
+ const mm = Math.floor(Number(iso.slice(14, 16)) / 5) * 5;
45
+ return hh + String(mm).padStart(2, "0");
46
+ };
40
47
  function ensureFlushLoop() {
41
48
  if (timer) return;
42
49
  timer = setInterval(() => void flush(), flushIntervalMs);
@@ -54,8 +61,10 @@ function record(resource, quantity, hint) {
54
61
  labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + quantity);
55
62
  const hk = date + SEP + resource + SEP + utcHour();
56
63
  hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + quantity);
64
+ const mk = date + SEP + resource + SEP + utcFiveMin();
65
+ minuteBuffer.set(mk, (minuteBuffer.get(mk) ?? 0) + quantity);
57
66
  ensureFlushLoop();
58
- if (labelBuffer.size + hourBuffer.size > MAX_BUFFER_KEYS) void flush();
67
+ if (labelBuffer.size + hourBuffer.size + minuteBuffer.size > MAX_BUFFER_KEYS) void flush();
59
68
  } catch {
60
69
  }
61
70
  }
@@ -77,20 +86,23 @@ async function flush() {
77
86
  if (!sink) {
78
87
  labelBuffer.clear();
79
88
  hourBuffer.clear();
89
+ minuteBuffer.clear();
80
90
  return;
81
91
  }
82
- if (labelBuffer.size === 0 && hourBuffer.size === 0) return;
92
+ if (labelBuffer.size === 0 && hourBuffer.size === 0 && minuteBuffer.size === 0) return;
83
93
  flushing = true;
84
94
  const labels = new Map(labelBuffer);
85
95
  const hours = new Map(hourBuffer);
96
+ const minutes = new Map(minuteBuffer);
86
97
  labelBuffer.clear();
87
98
  hourBuffer.clear();
99
+ minuteBuffer.clear();
88
100
  try {
89
101
  const byDate = /* @__PURE__ */ new Map();
90
102
  const reportFor = (date) => {
91
103
  let r = byDate.get(date);
92
104
  if (!r) {
93
- r = { date, byLabel: {}, byHour: {} };
105
+ r = { date, byLabel: {}, byHour: {}, byMinute: {} };
94
106
  byDate.set(date, r);
95
107
  }
96
108
  return r;
@@ -103,6 +115,10 @@ async function flush() {
103
115
  const [date, op, hour] = k.split(SEP);
104
116
  add(reportFor(date).byHour, hour, op, n);
105
117
  }
118
+ for (const [k, n] of minutes) {
119
+ const [date, op, slot] = k.split(SEP);
120
+ add(reportFor(date).byMinute, slot, op, n);
121
+ }
106
122
  for (const report of byDate.values()) {
107
123
  await sink.flush(report);
108
124
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/cost-context.ts","../src/cost-meter.ts","../src/sink.ts","../src/adapters/firestore.ts","../src/index.ts"],"names":["sink"],"mappings":"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","file":"index.mjs","sourcesContent":["/**\n * cost-context — the request-scoped tag every counted operation attributes\n * itself to. Set it ONCE at a boundary (or wrap a path with `bucket()`); it\n * rides Node's AsyncLocalStorage down through every async fan-out, so one\n * handler that triggers 15 reads attributes all 15 to the same bucket — with\n * zero per-call-site work.\n *\n * Generic by design: unlike a hardcoded product taxonomy, the only meaningful\n * field a consumer sets is the free-form `label` (the bucket name). `feature`\n * is an optional coarse grouping if you want one; nothing here is\n * Crossdeck-specific.\n */\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\nexport interface CostTag {\n /** Optional coarse grouping (a caller-defined surface name). */\n feature?: string;\n /** The bucket name — what `bucket()` sets. Drives the report's `byLabel`. */\n label?: string;\n}\n\nconst DEFAULT_TAG: CostTag = {};\nconst store = new AsyncLocalStorage<CostTag>();\n\n/** Run `fn` with `tag` bound for its entire async subtree. */\nexport function runWithCostTag<T>(tag: CostTag, fn: () => T): T {\n return store.run({ ...tag }, fn);\n}\n\n/** Bind a tag for the remainder of the current async context (no closure to wrap). */\nexport function enterCostTag(tag: CostTag): void {\n store.enterWith({ ...tag });\n}\n\n/** Refine the live tag in place (e.g. stamp a feature after the boundary). */\nexport function refineCostTag(patch: Partial<CostTag>): void {\n const cur = store.getStore();\n if (cur) Object.assign(cur, patch);\n}\n\n/** The current tag, or a safe empty default outside any bound context. */\nexport function currentCostTag(): CostTag {\n return store.getStore() ?? DEFAULT_TAG;\n}\n\n/**\n * `bucket(name, fn)` — the headline verb, the `track()` of cost. Run `fn` with\n * every operation inside it attributed to the bucket `name`; the attribution\n * rides the async subtree automatically. The one verb most developers ever touch:\n *\n * await bucket(\"nightly-export\", async () => {\n * const rows = await db.collection(\"events\").where(...).get(); // → \"nightly-export\"\n * });\n */\nexport function bucket<T>(name: string, fn: () => T): T {\n return runWithCostTag({ ...currentCostTag(), label: name }, fn);\n}\n","/**\n * cost-meter — counts operations against the ambient tag and flushes them to the\n * configured Sink cheaply.\n *\n * LOW-OVERHEAD CONTRACT (the thing that warns you about reads must not run them\n * up): counts accumulate in an in-memory buffer and flush periodically — NEVER one\n * network call per counted operation. A flush coalesces the whole window into one\n * report per UTC day and hands it to the Sink. At steady state that is ~1 small\n * request a minute, regardless of how many ops you served.\n *\n * BEST-EFFORT CONTRACT: metering must never throw into your code. Every recorder\n * swallows its own errors; a failed flush drops that window's counts (surfaced via\n * `onError` if you pass one) rather than disturbing the app.\n */\nimport { currentCostTag } from \"./cost-context\";\nimport type { Sink, BucketsReport, ResourceCounts } from \"./sink\";\n\n/**\n * A resource unit — what an adapter counts. Firestore emits `read`/`write`/\n * `delete`; other adapters emit their own (`clickhouse.query_ms`, `openai.tokens`).\n * It is a free identifier on purpose, BUT each one is kept entirely distinct: the\n * meter only ever sums quantities WITHIN a single resource, never across two.\n */\nexport type ResourceUnit = string;\n/** @deprecated — Firestore-era name; use {@link ResourceUnit}. */\nexport type OpType = ResourceUnit;\n\n/** Optional read-site hint — the collection touched, derived at the trap from the\n * path. Lets an UNtagged read cascade to `col:<collection>` instead of vanishing. */\nexport interface CostHint {\n collection?: string;\n projectId?: string;\n}\n\n// NUL separator — a bucket/collection name can contain almost anything except\n// this, so the key never collides with a name that has a \"|\" or \":\" in it.\nconst SEP = \"\\u001f\"; // ASCII Unit Separator\n\n/** key = date <NUL> op <NUL> label → count */\nconst labelBuffer = new Map<string, number>();\n/** key = date <NUL> op <NUL> hour → count */\nconst hourBuffer = new Map<string, number>();\n\nlet sink: Sink | null = null;\nlet flushIntervalMs = 60_000;\nlet onError: ((e: unknown) => void) | null = null;\nlet timer: ReturnType<typeof setInterval> | null = null;\nlet flushing = false;\n/** Safety valve — flush early if a burst fills the buffer between intervals. */\nconst MAX_BUFFER_KEYS = 5_000;\n\nexport interface MeterConfig {\n sink: Sink;\n flushIntervalMs?: number;\n onError?: (e: unknown) => void;\n}\n\n/** Point the meter at a sink. Called by `init()`; pass your own sink to self-host. */\nexport function configureMeter(config: MeterConfig): void {\n sink = config.sink;\n if (config.flushIntervalMs && config.flushIntervalMs > 0) flushIntervalMs = config.flushIntervalMs;\n onError = config.onError ?? null;\n}\n\nconst utcDate = (): string => new Date().toISOString().slice(0, 10);\nconst utcHour = (): string => new Date().toISOString().slice(11, 13);\n\nfunction ensureFlushLoop(): void {\n if (timer) return;\n timer = setInterval(() => void flush(), flushIntervalMs);\n // Don't keep the event loop alive just for metering.\n (timer as { unref?: () => void }).unref?.();\n // Flush the last window on shutdown.\n process.once?.(\"SIGTERM\", () => void flush());\n process.once?.(\"beforeExit\", () => void flush());\n}\n\n/**\n * Count `quantity` of a `resource` against the live tag. THE adapter primitive —\n * a Firestore adapter records \"read\"; a ClickHouse adapter records\n * \"clickhouse.query_ms\"; an OpenAI adapter records \"openai.tokens\". Each resource\n * is bucketed entirely on its own; nothing is ever added across resources. Never\n * throws.\n */\nexport function record(resource: ResourceUnit, quantity: number, hint?: CostHint): void {\n try {\n if (!Number.isFinite(quantity) || quantity <= 0) return;\n const t = currentCostTag();\n const date = utcDate();\n // CASCADE — every unit gets a label, by design (no blind spots): the bucket\n // name wins; else the collection it actually touched (`col:posts`); else\n // \"uncategorized\" as a loud last resort. A unit is never invisible.\n const label = t.label || (hint?.collection ? `col:${hint.collection}` : \"uncategorized\");\n // Key includes the resource, so each resource accumulates in its OWN slot.\n const lk = date + SEP + resource + SEP + label;\n labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + quantity);\n const hk = date + SEP + resource + SEP + utcHour();\n hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + quantity);\n ensureFlushLoop();\n if (labelBuffer.size + hourBuffer.size > MAX_BUFFER_KEYS) void flush();\n } catch {\n /* metering is best-effort — never disturb the caller */\n }\n}\n\n/** @deprecated — use {@link record}. Firestore-era alias. */\nexport const recordFirestore = record;\n\n/** Firestore bills a minimum of one read even for an empty result, so 0 counts as 1. */\nexport function recordReads(n: number, hint?: CostHint): void {\n record(\"read\", Math.max(n, 1), hint);\n}\nexport function recordWrites(n = 1): void {\n record(\"write\", n);\n}\nexport function recordDeletes(n = 1): void {\n record(\"delete\", n);\n}\n\nfunction add(target: Record<string, ResourceCounts>, key: string, resource: ResourceUnit, n: number): void {\n const bag = (target[key] ??= {});\n // Accumulate WITHIN this resource only — never merge resources.\n bag[resource] = (bag[resource] ?? 0) + n;\n}\n\n/**\n * Coalesce the buffer into one report per UTC day and hand each to the Sink.\n * Snapshots + clears up front so concurrent records land in the next window.\n * Never throws; a sink failure drops that window (surfaced via `onError`).\n */\nexport async function flush(): Promise<void> {\n if (flushing) return;\n // Not configured (init not called) — drop, don't grow unbounded.\n if (!sink) {\n labelBuffer.clear();\n hourBuffer.clear();\n return;\n }\n if (labelBuffer.size === 0 && hourBuffer.size === 0) return;\n flushing = true;\n\n const labels = new Map(labelBuffer);\n const hours = new Map(hourBuffer);\n labelBuffer.clear();\n hourBuffer.clear();\n\n try {\n const byDate = new Map<string, BucketsReport>();\n const reportFor = (date: string): BucketsReport => {\n let r = byDate.get(date);\n if (!r) {\n r = { date, byLabel: {}, byHour: {} };\n byDate.set(date, r);\n }\n return r;\n };\n for (const [k, n] of labels) {\n const [date, op, label] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byLabel, label, op, n);\n }\n for (const [k, n] of hours) {\n const [date, op, hour] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byHour!, hour, op, n);\n }\n for (const report of byDate.values()) {\n await sink.flush(report);\n }\n } catch (e) {\n // Drop this window rather than risk a partial/double report on retry.\n onError?.(e);\n } finally {\n flushing = false;\n }\n}\n","/**\n * sink — where the meter sends a coalesced rollup, and the wire shape it sends.\n *\n * Abstracting the sink is what makes Buckets storage-agnostic: the meter never\n * knows where counts go. The DEFAULT sink (`ReportSink`) reports up to Crossdeck's\n * ingest endpoint so the numbers surface on your Crossdeck dashboard. A team that\n * wants to self-host can implement `Sink` against anything (Postgres, a file, your\n * own API) without touching the meter.\n */\n\n/**\n * Counts for ONE bucket, keyed by RESOURCE UNIT — the raw quantity of each unit,\n * nothing more (no money; you verify cost on your provider's bill).\n *\n * Firestore, the first adapter, emits `read` / `write` / `delete`. Other adapters\n * emit their own units (`clickhouse.query_ms`, `openai.tokens`, …). The rule that\n * keeps this honest: **each resource is its own line. Counts are only ever summed\n * WITHIN a resource, NEVER across one.** A read is not a query-millisecond; the two\n * never land in the same number. (There is deliberately no \"total units\" field.)\n */\nexport interface ResourceCounts {\n read?: number;\n write?: number;\n delete?: number;\n /** Any other resource unit an adapter emits — kept distinct, never merged. */\n [resource: string]: number | undefined;\n}\n\n/** @deprecated name — kept for back-compat; use {@link ResourceCounts}. */\nexport type OpCounts = ResourceCounts;\n\n/**\n * One coalesced report — the wire contract (see docs/ROLLUP_SCHEMA.md). The meter\n * produces one of these per UTC day in a flush window (usually exactly one).\n */\nexport interface BucketsReport {\n /** UTC day \"YYYY-MM-DD\". */\n date: string;\n /** bucket name → counts. The heart of the report. */\n byLabel: Record<string, OpCounts>;\n /** UTC hour \"HH\" → counts, for the hourly \"did my fix land this hour?\" view. */\n byHour?: Record<string, OpCounts>;\n}\n\n/**\n * A destination for coalesced rollups. `flush` MAY throw on failure — the meter\n * catches it, drops that one window, and never lets it reach your app.\n */\nexport interface Sink {\n flush(report: BucketsReport): Promise<void>;\n}\n\nexport interface ReportSinkConfig {\n /** The project's `cd_sk_` secret key. Server-to-server only. */\n apiKey: string;\n /** Defaults to https://api.cross-deck.com/v1/buckets/report */\n endpoint?: string;\n /** Request timeout (ms); a slow Crossdeck must never stall your flush. */\n timeoutMs?: number;\n}\n\nconst DEFAULT_ENDPOINT = \"https://api.cross-deck.com/v1/buckets/report\";\n\n/**\n * The default sink: POST one coalesced rollup to Crossdeck's ingest endpoint.\n * The ingest folds it into the day's maintained doc with `increment`, so many\n * reports a minute coalesce safely. This path does ZERO database reads — it sends\n * a summary, it does not read. Throws on a non-202 so the meter can log/drop the\n * window; the meter guarantees it never reaches your app.\n */\nexport class ReportSink implements Sink {\n private readonly endpoint: string;\n private readonly apiKey: string;\n private readonly timeoutMs: number;\n\n constructor(config: ReportSinkConfig) {\n this.endpoint = config.endpoint ?? DEFAULT_ENDPOINT;\n this.apiKey = config.apiKey;\n this.timeoutMs = config.timeoutMs ?? 5000;\n }\n\n async flush(report: BucketsReport): Promise<void> {\n const res = await fetch(this.endpoint, {\n method: \"POST\",\n signal: AbortSignal.timeout(this.timeoutMs),\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${this.apiKey}`,\n },\n body: JSON.stringify(report),\n });\n if (res.status !== 202) {\n throw new Error(`Buckets report rejected: HTTP ${res.status}`);\n }\n }\n}\n","/**\n * adapters/firestore — the universal Firestore read meter (the trap).\n *\n * THE LESSON (learned on a real product): per-call-site `recordReads()`\n * instrumentation MISSES paths. You meter the read sites you're looking at and\n * leave the cron / trigger / ingest path uncounted — often the majority of reads,\n * invisible. Humans tag what they see and miss the path that matters.\n *\n * THE FIX: patch the admin SDK's read methods ONCE. From install onward, EVERY\n * read — anywhere, on any code path — is counted under the ambient tag, with zero\n * per-call-site work and no blind spots.\n *\n * SAFETY CONTRACT — this sits on your production read path, so it is defensive by\n * construction. Each wrapper:\n * 1. calls the REAL method first and captures the result,\n * 2. counts in a try/catch that can never throw into the caller,\n * 3. ALWAYS returns the real result, untouched.\n * It cannot break a read, change a result, or add latency beyond one in-memory\n * counter increment. A wrong count is a measurement error, never a correctness or\n * availability one. Idempotent — calling it twice patches once.\n *\n * COUNTING MODEL — a query returning N docs = N reads (an empty result still bills\n * 1, which the meter enforces). A document get = 1. getAll(...) = the ref count.\n * CollectionReference.get IS Query.get (shared prototype method), so patching Query\n * covers collections with no double-count.\n */\nimport { recordReads, type CostHint } from \"../cost-meter\";\n\nlet installed = false;\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\ntype AnyFn = (...args: any[]) => any;\n\n/**\n * The firebase-admin Firestore classes to patch. Pass the module namespace from\n * `firebase-admin/firestore` — only the prototypes present are patched.\n */\nexport interface FirestoreClasses {\n Query?: { prototype: { get?: AnyFn; onSnapshot?: AnyFn } };\n DocumentReference?: { prototype: { get?: AnyFn; onSnapshot?: AnyFn } };\n Transaction?: { prototype: { get?: AnyFn; getAll?: AnyFn } };\n Firestore?: { prototype: { getAll?: AnyFn } };\n /** count() / sum() / average() — aggregation queries bill reads too. */\n AggregateQuery?: { prototype: { get?: AnyFn } };\n}\n\n/** `projects/{id}/…` → the project id, else undefined. Pure string op. */\nfunction projectFromPath(path: string): string | undefined {\n const parts = path.split(\"/\");\n const i = parts.indexOf(\"projects\");\n return i >= 0 && parts[i + 1] ? parts[i + 1] : undefined;\n}\n\n/**\n * Derive { collection, projectId } from the read target's path so an UNtagged read\n * cascades to `col:<collection>` instead of \"uncategorized\". PURE CPU; never reads,\n * never throws. Falls back to firebase-admin's internal `_queryOptions` for filtered\n * queries (which don't expose `.path`).\n */\nfunction hintFrom(target: any): CostHint | undefined {\n try {\n const p = typeof target?.path === \"string\" ? target.path : \"\";\n if (p) {\n const parts = p.split(\"/\").filter(Boolean);\n const collection = parts.length % 2 === 0 ? parts[parts.length - 2] : parts[parts.length - 1];\n return { collection, projectId: projectFromPath(p) };\n }\n const qo = target?._queryOptions;\n if (qo) {\n const collection = typeof qo.collectionId === \"string\" ? qo.collectionId : undefined;\n const parent =\n typeof qo.parentPath?.relativeName === \"string\"\n ? qo.parentPath.relativeName\n : typeof qo.parentPath?.toString === \"function\"\n ? String(qo.parentPath.toString())\n : \"\";\n return { collection, projectId: parent ? projectFromPath(parent) : undefined };\n }\n } catch {\n /* the meter must never throw */\n }\n return undefined;\n}\n\nfunction meterSnap(snap: unknown, hint?: CostHint): void {\n try {\n const size = (snap as { size?: number } | null)?.size;\n recordReads(typeof size === \"number\" ? size : 1, hint);\n } catch {\n /* best-effort */\n }\n}\nfunction meterCount(n: number, hint?: CostHint): void {\n try {\n recordReads(n, hint);\n } catch {\n /* best-effort */\n }\n}\n\n/**\n * Install the universal read meter on the firebase-admin Firestore classes. Call\n * ONCE at process start, before any reads. Pass the namespace from\n * `firebase-admin/firestore` so the exact prototypes the SDK uses are patched:\n *\n * import * as Firestore from \"firebase-admin/firestore\";\n * installFirestoreMeter(Firestore);\n */\nexport function installFirestoreMeter(classes: FirestoreClasses): void {\n if (installed) return;\n installed = true;\n const { Query, DocumentReference, Transaction, Firestore, AggregateQuery } = classes;\n\n // Query.get — covers Query AND CollectionReference (shared prototype method).\n const qGet = Query?.prototype?.get;\n if (qGet) {\n Query!.prototype.get = async function (this: unknown, ...args: any[]) {\n const snap = await qGet.apply(this, args);\n meterSnap(snap, hintFrom(this));\n return snap;\n };\n }\n\n // DocumentReference.get — a single doc = 1 read.\n const dGet = DocumentReference?.prototype?.get;\n if (dGet) {\n DocumentReference!.prototype.get = async function (this: unknown, ...args: any[]) {\n const snap = await dGet.apply(this, args);\n meterCount(1, hintFrom(this));\n return snap;\n };\n }\n\n // Transaction.get — query or doc; size when present, else 1.\n const tGet = Transaction?.prototype?.get;\n if (tGet) {\n Transaction!.prototype.get = async function (this: unknown, ...args: any[]) {\n const res = await tGet.apply(this, args);\n meterSnap(res, hintFrom(args[0]));\n return res;\n };\n }\n\n // Transaction.getAll(...refs) — one read per ref.\n const tGetAll = Transaction?.prototype?.getAll;\n if (tGetAll) {\n Transaction!.prototype.getAll = async function (this: unknown, ...args: any[]) {\n const res = await tGetAll.apply(this, args);\n meterCount(Array.isArray(res) ? res.length : args.length || 1, hintFrom(args[0]));\n return res;\n };\n }\n\n // Firestore.getAll(...refs) — batched doc reads.\n const fGetAll = Firestore?.prototype?.getAll;\n if (fGetAll) {\n Firestore!.prototype.getAll = async function (this: unknown, ...args: any[]) {\n const res = await fGetAll.apply(this, args);\n meterCount(Array.isArray(res) ? res.length : args.length || 1, hintFrom(args[0]));\n return res;\n };\n }\n\n // AggregateQuery.get — count() / sum() / average(). Firestore bills one read per\n // up to 1000 index entries scanned, and the client never sees that entry count —\n // so this is an HONEST ESTIMATE: for count() we derive it from the result\n // (ceil(count / 1000)), else the billed minimum of 1. Observe-only: it reads the\n // result you already got, adds zero reads. (Closes the aggregation blind spot.)\n const aGet = AggregateQuery?.prototype?.get;\n if (aGet) {\n AggregateQuery!.prototype.get = async function (this: unknown, ...args: any[]) {\n const snap = await aGet.apply(this, args);\n try {\n const data = (snap as { data?: () => { count?: number } } | null)?.data?.();\n const count = typeof data?.count === \"number\" ? data.count : 0;\n meterCount(Math.max(1, Math.ceil(count / 1000)), hintFrom(this));\n } catch {\n /* best-effort */\n }\n return snap;\n };\n }\n\n // Query.onSnapshot / DocumentReference.onSnapshot — server-side realtime\n // listeners. We OBSERVE: wrap onNext and count the docs each fire delivers (a\n // query's changed docs — first fire = all matching; a doc = 1). We attach no\n // listener and issue no read; the meter just sees what the listener is already\n // billed. (Closes the server-listener blind spot.)\n const patchOnSnapshot = (proto: { onSnapshot?: AnyFn } | undefined, perDoc: boolean): void => {\n const real = proto?.onSnapshot;\n if (!real) return;\n proto!.onSnapshot = function (this: unknown, ...args: any[]) {\n const hint = hintFrom(this);\n const i = args.findIndex((a) => typeof a === \"function\");\n if (i >= 0) {\n const onNext = args[i];\n args[i] = function (snap: any) {\n try {\n const n = perDoc\n ? 1\n : typeof snap?.docChanges === \"function\"\n ? snap.docChanges().length\n : typeof snap?.size === \"number\"\n ? snap.size\n : 1;\n if (n > 0) meterCount(n, hint);\n } catch {\n /* best-effort */\n }\n return onNext(snap);\n };\n }\n return real.apply(this, args);\n };\n };\n patchOnSnapshot(Query?.prototype, false);\n patchOnSnapshot(DocumentReference?.prototype, true);\n}\n\n/** Test-only: reset the install guard so a suite can re-patch fresh prototypes. */\nexport function __resetFirestoreMeterForTests(): void {\n installed = false;\n}\n","/**\n * @cross-deck/buckets — know exactly what every database read costs you, and who\n * caused it. A tiny, never-throws collector for Firestore.\n *\n * The whole footprint a consumer sees:\n * 1. init({ apiKey, firestore }) — configure once, install the trap once\n * 2. bucket(name, fn) — name the read paths that matter\n * 3. (the dashboard shows the rest — and names the ones you haven't yet)\n */\nimport { configureMeter, type MeterConfig } from \"./cost-meter\";\nimport { ReportSink, type Sink } from \"./sink\";\nimport { installFirestoreMeter, type FirestoreClasses } from \"./adapters/firestore\";\n\nexport interface InitOptions {\n /** The project's `cd_sk_` SECRET key. Server-to-server only — never a browser key. */\n apiKey: string;\n /**\n * Pass the namespace from `firebase-admin/firestore` to auto-install the read\n * trap (recommended — this is what makes every read count with no per-call work).\n * Omit it if you'd rather call `installFirestoreMeter()` yourself, or you only\n * use the manual `recordReads()` recorders.\n */\n firestore?: FirestoreClasses;\n /** Override the report endpoint (defaults to Crossdeck's ingest). */\n endpoint?: string;\n /** How often to flush coalesced counts (ms). Default 60_000. */\n flushIntervalMs?: number;\n /** Bring your own sink (self-host the rollups). Defaults to reporting up to Crossdeck. */\n sink?: Sink;\n /** Notified when a flush fails, so a dropped window is never silent. Best-effort. */\n onError?: MeterConfig[\"onError\"];\n}\n\n/**\n * Configure Buckets once, at process start. Points the meter at a sink (Crossdeck's\n * ingest by default) and — if you pass `firestore` — installs the universal read\n * trap so every read counts automatically.\n */\nexport function init(options: InitOptions): void {\n const sink = options.sink ?? new ReportSink({ apiKey: options.apiKey, endpoint: options.endpoint });\n configureMeter({ sink, flushIntervalMs: options.flushIntervalMs, onError: options.onError });\n if (options.firestore) installFirestoreMeter(options.firestore);\n}\n\n/** Alias — reads well next to `bucket()` at a call site. */\nexport { init as initBuckets };\n\n// The headline verb + the lower-level tag controls it is sugar over.\nexport {\n bucket,\n runWithCostTag,\n enterCostTag,\n refineCostTag,\n currentCostTag,\n type CostTag,\n} from \"./cost-context\";\n\n// Recorders. `record(resource, quantity)` is the generic adapter primitive — count\n// any resource unit (a future adapter records \"clickhouse.query_ms\"); recordReads/\n// Writes/Deletes are the Firestore conveniences over it.\nexport {\n record,\n recordReads,\n recordWrites,\n recordDeletes,\n flush,\n type CostHint,\n type ResourceUnit,\n type OpType,\n type MeterConfig,\n} from \"./cost-meter\";\n\n// The trap (the only datastore adapter today) + its class shape.\nexport { installFirestoreMeter, type FirestoreClasses } from \"./adapters/firestore\";\n\n// The sink seam — for self-hosting rollups instead of reporting to Crossdeck.\nexport {\n ReportSink,\n type Sink,\n type BucketsReport,\n type ResourceCounts,\n type OpCounts,\n type ReportSinkConfig,\n} from \"./sink\";\n"]}
1
+ {"version":3,"sources":["../src/cost-context.ts","../src/cost-meter.ts","../src/sink.ts","../src/adapters/firestore.ts","../src/index.ts"],"names":["sink"],"mappings":"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","file":"index.mjs","sourcesContent":["/**\n * cost-context — the request-scoped tag every counted operation attributes\n * itself to. Set it ONCE at a boundary (or wrap a path with `bucket()`); it\n * rides Node's AsyncLocalStorage down through every async fan-out, so one\n * handler that triggers 15 reads attributes all 15 to the same bucket — with\n * zero per-call-site work.\n *\n * Generic by design: unlike a hardcoded product taxonomy, the only meaningful\n * field a consumer sets is the free-form `label` (the bucket name). `feature`\n * is an optional coarse grouping if you want one; nothing here is\n * Crossdeck-specific.\n */\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\nexport interface CostTag {\n /** Optional coarse grouping (a caller-defined surface name). */\n feature?: string;\n /** The bucket name — what `bucket()` sets. Drives the report's `byLabel`. */\n label?: string;\n}\n\nconst DEFAULT_TAG: CostTag = {};\nconst store = new AsyncLocalStorage<CostTag>();\n\n/** Run `fn` with `tag` bound for its entire async subtree. */\nexport function runWithCostTag<T>(tag: CostTag, fn: () => T): T {\n return store.run({ ...tag }, fn);\n}\n\n/** Bind a tag for the remainder of the current async context (no closure to wrap). */\nexport function enterCostTag(tag: CostTag): void {\n store.enterWith({ ...tag });\n}\n\n/** Refine the live tag in place (e.g. stamp a feature after the boundary). */\nexport function refineCostTag(patch: Partial<CostTag>): void {\n const cur = store.getStore();\n if (cur) Object.assign(cur, patch);\n}\n\n/** The current tag, or a safe empty default outside any bound context. */\nexport function currentCostTag(): CostTag {\n return store.getStore() ?? DEFAULT_TAG;\n}\n\n/**\n * `bucket(name, fn)` — the headline verb, the `track()` of cost. Run `fn` with\n * every operation inside it attributed to the bucket `name`; the attribution\n * rides the async subtree automatically. The one verb most developers ever touch:\n *\n * await bucket(\"nightly-export\", async () => {\n * const rows = await db.collection(\"events\").where(...).get(); // → \"nightly-export\"\n * });\n */\nexport function bucket<T>(name: string, fn: () => T): T {\n return runWithCostTag({ ...currentCostTag(), label: name }, fn);\n}\n","/**\n * cost-meter — counts operations against the ambient tag and flushes them to the\n * configured Sink cheaply.\n *\n * LOW-OVERHEAD CONTRACT (the thing that warns you about reads must not run them\n * up): counts accumulate in an in-memory buffer and flush periodically — NEVER one\n * network call per counted operation. A flush coalesces the whole window into one\n * report per UTC day and hands it to the Sink. At steady state that is ~1 small\n * request a minute, regardless of how many ops you served.\n *\n * BEST-EFFORT CONTRACT: metering must never throw into your code. Every recorder\n * swallows its own errors; a failed flush drops that window's counts (surfaced via\n * `onError` if you pass one) rather than disturbing the app.\n */\nimport { currentCostTag } from \"./cost-context\";\nimport type { Sink, BucketsReport, ResourceCounts } from \"./sink\";\n\n/**\n * A resource unit — what an adapter counts. Firestore emits `read`/`write`/\n * `delete`; other adapters emit their own (`clickhouse.query_ms`, `openai.tokens`).\n * It is a free identifier on purpose, BUT each one is kept entirely distinct: the\n * meter only ever sums quantities WITHIN a single resource, never across two.\n */\nexport type ResourceUnit = string;\n/** @deprecated — Firestore-era name; use {@link ResourceUnit}. */\nexport type OpType = ResourceUnit;\n\n/** Optional read-site hint — the collection touched, derived at the trap from the\n * path. Lets an UNtagged read cascade to `col:<collection>` instead of vanishing. */\nexport interface CostHint {\n collection?: string;\n projectId?: string;\n}\n\n// NUL separator — a bucket/collection name can contain almost anything except\n// this, so the key never collides with a name that has a \"|\" or \":\" in it.\nconst SEP = \"\\u001f\"; // ASCII Unit Separator\n\n/** key = date <NUL> resource <NUL> label → count */\nconst labelBuffer = new Map<string, number>();\n/** key = date <NUL> resource <NUL> hour → count */\nconst hourBuffer = new Map<string, number>();\n/** key = date <NUL> resource <NUL> 5-min-slot (\"HHMM\") → count. The fine grain\n * that lets a developer verify against the provider console's \"last hour\" within\n * minutes of installing — not after a day. Still one maintained doc, zero reads. */\nconst minuteBuffer = new Map<string, number>();\n\nlet sink: Sink | null = null;\nlet flushIntervalMs = 60_000;\nlet onError: ((e: unknown) => void) | null = null;\nlet timer: ReturnType<typeof setInterval> | null = null;\nlet flushing = false;\n/** Safety valve — flush early if a burst fills the buffer between intervals. */\nconst MAX_BUFFER_KEYS = 5_000;\n\nexport interface MeterConfig {\n sink: Sink;\n flushIntervalMs?: number;\n onError?: (e: unknown) => void;\n}\n\n/** Point the meter at a sink. Called by `init()`; pass your own sink to self-host. */\nexport function configureMeter(config: MeterConfig): void {\n sink = config.sink;\n if (config.flushIntervalMs && config.flushIntervalMs > 0) flushIntervalMs = config.flushIntervalMs;\n onError = config.onError ?? null;\n}\n\nconst utcDate = (): string => new Date().toISOString().slice(0, 10);\nconst utcHour = (): string => new Date().toISOString().slice(11, 13);\n/** UTC 5-minute slot of the day as \"HHMM\" — the slot START (e.g. 08:47 → \"0845\"). */\nconst utcFiveMin = (): string => {\n const iso = new Date().toISOString();\n const hh = iso.slice(11, 13);\n const mm = Math.floor(Number(iso.slice(14, 16)) / 5) * 5;\n return hh + String(mm).padStart(2, \"0\");\n};\n\nfunction ensureFlushLoop(): void {\n if (timer) return;\n timer = setInterval(() => void flush(), flushIntervalMs);\n // Don't keep the event loop alive just for metering.\n (timer as { unref?: () => void }).unref?.();\n // Flush the last window on shutdown.\n process.once?.(\"SIGTERM\", () => void flush());\n process.once?.(\"beforeExit\", () => void flush());\n}\n\n/**\n * Count `quantity` of a `resource` against the live tag. THE adapter primitive —\n * a Firestore adapter records \"read\"; a ClickHouse adapter records\n * \"clickhouse.query_ms\"; an OpenAI adapter records \"openai.tokens\". Each resource\n * is bucketed entirely on its own; nothing is ever added across resources. Never\n * throws.\n */\nexport function record(resource: ResourceUnit, quantity: number, hint?: CostHint): void {\n try {\n if (!Number.isFinite(quantity) || quantity <= 0) return;\n const t = currentCostTag();\n const date = utcDate();\n // CASCADE — every unit gets a label, by design (no blind spots): the bucket\n // name wins; else the collection it actually touched (`col:posts`); else\n // \"uncategorized\" as a loud last resort. A unit is never invisible.\n const label = t.label || (hint?.collection ? `col:${hint.collection}` : \"uncategorized\");\n // Key includes the resource, so each resource accumulates in its OWN slot.\n const lk = date + SEP + resource + SEP + label;\n labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + quantity);\n const hk = date + SEP + resource + SEP + utcHour();\n hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + quantity);\n const mk = date + SEP + resource + SEP + utcFiveMin();\n minuteBuffer.set(mk, (minuteBuffer.get(mk) ?? 0) + quantity);\n ensureFlushLoop();\n if (labelBuffer.size + hourBuffer.size + minuteBuffer.size > MAX_BUFFER_KEYS) void flush();\n } catch {\n /* metering is best-effort — never disturb the caller */\n }\n}\n\n/** @deprecated — use {@link record}. Firestore-era alias. */\nexport const recordFirestore = record;\n\n/** Firestore bills a minimum of one read even for an empty result, so 0 counts as 1. */\nexport function recordReads(n: number, hint?: CostHint): void {\n record(\"read\", Math.max(n, 1), hint);\n}\nexport function recordWrites(n = 1): void {\n record(\"write\", n);\n}\nexport function recordDeletes(n = 1): void {\n record(\"delete\", n);\n}\n\nfunction add(target: Record<string, ResourceCounts>, key: string, resource: ResourceUnit, n: number): void {\n const bag = (target[key] ??= {});\n // Accumulate WITHIN this resource only — never merge resources.\n bag[resource] = (bag[resource] ?? 0) + n;\n}\n\n/**\n * Coalesce the buffer into one report per UTC day and hand each to the Sink.\n * Snapshots + clears up front so concurrent records land in the next window.\n * Never throws; a sink failure drops that window (surfaced via `onError`).\n */\nexport async function flush(): Promise<void> {\n if (flushing) return;\n // Not configured (init not called) — drop, don't grow unbounded.\n if (!sink) {\n labelBuffer.clear();\n hourBuffer.clear();\n minuteBuffer.clear();\n return;\n }\n if (labelBuffer.size === 0 && hourBuffer.size === 0 && minuteBuffer.size === 0) return;\n flushing = true;\n\n const labels = new Map(labelBuffer);\n const hours = new Map(hourBuffer);\n const minutes = new Map(minuteBuffer);\n labelBuffer.clear();\n hourBuffer.clear();\n minuteBuffer.clear();\n\n try {\n const byDate = new Map<string, BucketsReport>();\n const reportFor = (date: string): BucketsReport => {\n let r = byDate.get(date);\n if (!r) {\n r = { date, byLabel: {}, byHour: {}, byMinute: {} };\n byDate.set(date, r);\n }\n return r;\n };\n for (const [k, n] of labels) {\n const [date, op, label] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byLabel, label, op, n);\n }\n for (const [k, n] of hours) {\n const [date, op, hour] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byHour!, hour, op, n);\n }\n for (const [k, n] of minutes) {\n const [date, op, slot] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byMinute!, slot, op, n);\n }\n for (const report of byDate.values()) {\n await sink.flush(report);\n }\n } catch (e) {\n // Drop this window rather than risk a partial/double report on retry.\n onError?.(e);\n } finally {\n flushing = false;\n }\n}\n","/**\n * sink — where the meter sends a coalesced rollup, and the wire shape it sends.\n *\n * Abstracting the sink is what makes Buckets storage-agnostic: the meter never\n * knows where counts go. The DEFAULT sink (`ReportSink`) reports up to Crossdeck's\n * ingest endpoint so the numbers surface on your Crossdeck dashboard. A team that\n * wants to self-host can implement `Sink` against anything (Postgres, a file, your\n * own API) without touching the meter.\n */\n\n/**\n * Counts for ONE bucket, keyed by RESOURCE UNIT — the raw quantity of each unit,\n * nothing more (no money; you verify cost on your provider's bill).\n *\n * Firestore, the first adapter, emits `read` / `write` / `delete`. Other adapters\n * emit their own units (`clickhouse.query_ms`, `openai.tokens`, …). The rule that\n * keeps this honest: **each resource is its own line. Counts are only ever summed\n * WITHIN a resource, NEVER across one.** A read is not a query-millisecond; the two\n * never land in the same number. (There is deliberately no \"total units\" field.)\n */\nexport interface ResourceCounts {\n read?: number;\n write?: number;\n delete?: number;\n /** Any other resource unit an adapter emits — kept distinct, never merged. */\n [resource: string]: number | undefined;\n}\n\n/** @deprecated name — kept for back-compat; use {@link ResourceCounts}. */\nexport type OpCounts = ResourceCounts;\n\n/**\n * One coalesced report — the wire contract (see docs/ROLLUP_SCHEMA.md). The meter\n * produces one of these per UTC day in a flush window (usually exactly one).\n */\nexport interface BucketsReport {\n /** UTC day \"YYYY-MM-DD\". */\n date: string;\n /** bucket name → counts. The heart of the report. */\n byLabel: Record<string, ResourceCounts>;\n /** UTC hour \"HH\" → counts, for the hourly \"did my fix land this hour?\" view. */\n byHour?: Record<string, ResourceCounts>;\n /** UTC 5-minute slot \"HHMM\" (slot start) → counts. The fine grain for fast\n * verification against a provider console's \"last hour\" view. */\n byMinute?: Record<string, ResourceCounts>;\n}\n\n/**\n * A destination for coalesced rollups. `flush` MAY throw on failure — the meter\n * catches it, drops that one window, and never lets it reach your app.\n */\nexport interface Sink {\n flush(report: BucketsReport): Promise<void>;\n}\n\nexport interface ReportSinkConfig {\n /** The project's `cd_sk_` secret key. Server-to-server only. */\n apiKey: string;\n /** Defaults to https://api.cross-deck.com/v1/buckets/report */\n endpoint?: string;\n /** Request timeout (ms); a slow Crossdeck must never stall your flush. */\n timeoutMs?: number;\n}\n\nconst DEFAULT_ENDPOINT = \"https://api.cross-deck.com/v1/buckets/report\";\n\n/**\n * The default sink: POST one coalesced rollup to Crossdeck's ingest endpoint.\n * The ingest folds it into the day's maintained doc with `increment`, so many\n * reports a minute coalesce safely. This path does ZERO database reads — it sends\n * a summary, it does not read. Throws on a non-202 so the meter can log/drop the\n * window; the meter guarantees it never reaches your app.\n */\nexport class ReportSink implements Sink {\n private readonly endpoint: string;\n private readonly apiKey: string;\n private readonly timeoutMs: number;\n\n constructor(config: ReportSinkConfig) {\n this.endpoint = config.endpoint ?? DEFAULT_ENDPOINT;\n this.apiKey = config.apiKey;\n this.timeoutMs = config.timeoutMs ?? 5000;\n }\n\n async flush(report: BucketsReport): Promise<void> {\n const res = await fetch(this.endpoint, {\n method: \"POST\",\n signal: AbortSignal.timeout(this.timeoutMs),\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${this.apiKey}`,\n },\n body: JSON.stringify(report),\n });\n if (res.status !== 202) {\n throw new Error(`Buckets report rejected: HTTP ${res.status}`);\n }\n }\n}\n","/**\n * adapters/firestore — the universal Firestore read meter (the trap).\n *\n * THE LESSON (learned on a real product): per-call-site `recordReads()`\n * instrumentation MISSES paths. You meter the read sites you're looking at and\n * leave the cron / trigger / ingest path uncounted — often the majority of reads,\n * invisible. Humans tag what they see and miss the path that matters.\n *\n * THE FIX: patch the admin SDK's read methods ONCE. From install onward, EVERY\n * read — anywhere, on any code path — is counted under the ambient tag, with zero\n * per-call-site work and no blind spots.\n *\n * SAFETY CONTRACT — this sits on your production read path, so it is defensive by\n * construction. Each wrapper:\n * 1. calls the REAL method first and captures the result,\n * 2. counts in a try/catch that can never throw into the caller,\n * 3. ALWAYS returns the real result, untouched.\n * It cannot break a read, change a result, or add latency beyond one in-memory\n * counter increment. A wrong count is a measurement error, never a correctness or\n * availability one. Idempotent — calling it twice patches once.\n *\n * COUNTING MODEL — a query returning N docs = N reads (an empty result still bills\n * 1, which the meter enforces). A document get = 1. getAll(...) = the ref count.\n * CollectionReference.get IS Query.get (shared prototype method), so patching Query\n * covers collections with no double-count.\n */\nimport { recordReads, type CostHint } from \"../cost-meter\";\n\nlet installed = false;\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\ntype AnyFn = (...args: any[]) => any;\n\n/**\n * The firebase-admin Firestore classes to patch. Pass the module namespace from\n * `firebase-admin/firestore` — only the prototypes present are patched.\n */\nexport interface FirestoreClasses {\n Query?: { prototype: { get?: AnyFn; onSnapshot?: AnyFn } };\n DocumentReference?: { prototype: { get?: AnyFn; onSnapshot?: AnyFn } };\n Transaction?: { prototype: { get?: AnyFn; getAll?: AnyFn } };\n Firestore?: { prototype: { getAll?: AnyFn } };\n /** count() / sum() / average() — aggregation queries bill reads too. */\n AggregateQuery?: { prototype: { get?: AnyFn } };\n}\n\n/** `projects/{id}/…` → the project id, else undefined. Pure string op. */\nfunction projectFromPath(path: string): string | undefined {\n const parts = path.split(\"/\");\n const i = parts.indexOf(\"projects\");\n return i >= 0 && parts[i + 1] ? parts[i + 1] : undefined;\n}\n\n/**\n * Derive { collection, projectId } from the read target's path so an UNtagged read\n * cascades to `col:<collection>` instead of \"uncategorized\". PURE CPU; never reads,\n * never throws. Falls back to firebase-admin's internal `_queryOptions` for filtered\n * queries (which don't expose `.path`).\n */\nfunction hintFrom(target: any): CostHint | undefined {\n try {\n const p = typeof target?.path === \"string\" ? target.path : \"\";\n if (p) {\n const parts = p.split(\"/\").filter(Boolean);\n const collection = parts.length % 2 === 0 ? parts[parts.length - 2] : parts[parts.length - 1];\n return { collection, projectId: projectFromPath(p) };\n }\n const qo = target?._queryOptions;\n if (qo) {\n const collection = typeof qo.collectionId === \"string\" ? qo.collectionId : undefined;\n const parent =\n typeof qo.parentPath?.relativeName === \"string\"\n ? qo.parentPath.relativeName\n : typeof qo.parentPath?.toString === \"function\"\n ? String(qo.parentPath.toString())\n : \"\";\n return { collection, projectId: parent ? projectFromPath(parent) : undefined };\n }\n } catch {\n /* the meter must never throw */\n }\n return undefined;\n}\n\nfunction meterSnap(snap: unknown, hint?: CostHint): void {\n try {\n const size = (snap as { size?: number } | null)?.size;\n recordReads(typeof size === \"number\" ? size : 1, hint);\n } catch {\n /* best-effort */\n }\n}\nfunction meterCount(n: number, hint?: CostHint): void {\n try {\n recordReads(n, hint);\n } catch {\n /* best-effort */\n }\n}\n\n/**\n * Install the universal read meter on the firebase-admin Firestore classes. Call\n * ONCE at process start, before any reads. Pass the namespace from\n * `firebase-admin/firestore` so the exact prototypes the SDK uses are patched:\n *\n * import * as Firestore from \"firebase-admin/firestore\";\n * installFirestoreMeter(Firestore);\n */\nexport function installFirestoreMeter(classes: FirestoreClasses): void {\n if (installed) return;\n installed = true;\n const { Query, DocumentReference, Transaction, Firestore, AggregateQuery } = classes;\n\n // Query.get — covers Query AND CollectionReference (shared prototype method).\n const qGet = Query?.prototype?.get;\n if (qGet) {\n Query!.prototype.get = async function (this: unknown, ...args: any[]) {\n const snap = await qGet.apply(this, args);\n meterSnap(snap, hintFrom(this));\n return snap;\n };\n }\n\n // DocumentReference.get — a single doc = 1 read.\n const dGet = DocumentReference?.prototype?.get;\n if (dGet) {\n DocumentReference!.prototype.get = async function (this: unknown, ...args: any[]) {\n const snap = await dGet.apply(this, args);\n meterCount(1, hintFrom(this));\n return snap;\n };\n }\n\n // Transaction.get — query or doc; size when present, else 1.\n const tGet = Transaction?.prototype?.get;\n if (tGet) {\n Transaction!.prototype.get = async function (this: unknown, ...args: any[]) {\n const res = await tGet.apply(this, args);\n meterSnap(res, hintFrom(args[0]));\n return res;\n };\n }\n\n // Transaction.getAll(...refs) — one read per ref.\n const tGetAll = Transaction?.prototype?.getAll;\n if (tGetAll) {\n Transaction!.prototype.getAll = async function (this: unknown, ...args: any[]) {\n const res = await tGetAll.apply(this, args);\n meterCount(Array.isArray(res) ? res.length : args.length || 1, hintFrom(args[0]));\n return res;\n };\n }\n\n // Firestore.getAll(...refs) — batched doc reads.\n const fGetAll = Firestore?.prototype?.getAll;\n if (fGetAll) {\n Firestore!.prototype.getAll = async function (this: unknown, ...args: any[]) {\n const res = await fGetAll.apply(this, args);\n meterCount(Array.isArray(res) ? res.length : args.length || 1, hintFrom(args[0]));\n return res;\n };\n }\n\n // AggregateQuery.get — count() / sum() / average(). Firestore bills one read per\n // up to 1000 index entries scanned, and the client never sees that entry count —\n // so this is an HONEST ESTIMATE: for count() we derive it from the result\n // (ceil(count / 1000)), else the billed minimum of 1. Observe-only: it reads the\n // result you already got, adds zero reads. (Closes the aggregation blind spot.)\n const aGet = AggregateQuery?.prototype?.get;\n if (aGet) {\n AggregateQuery!.prototype.get = async function (this: unknown, ...args: any[]) {\n const snap = await aGet.apply(this, args);\n try {\n const data = (snap as { data?: () => { count?: number } } | null)?.data?.();\n const count = typeof data?.count === \"number\" ? data.count : 0;\n meterCount(Math.max(1, Math.ceil(count / 1000)), hintFrom(this));\n } catch {\n /* best-effort */\n }\n return snap;\n };\n }\n\n // Query.onSnapshot / DocumentReference.onSnapshot — server-side realtime\n // listeners. We OBSERVE: wrap onNext and count the docs each fire delivers (a\n // query's changed docs — first fire = all matching; a doc = 1). We attach no\n // listener and issue no read; the meter just sees what the listener is already\n // billed. (Closes the server-listener blind spot.)\n const patchOnSnapshot = (proto: { onSnapshot?: AnyFn } | undefined, perDoc: boolean): void => {\n const real = proto?.onSnapshot;\n if (!real) return;\n proto!.onSnapshot = function (this: unknown, ...args: any[]) {\n const hint = hintFrom(this);\n const i = args.findIndex((a) => typeof a === \"function\");\n if (i >= 0) {\n const onNext = args[i];\n args[i] = function (snap: any) {\n try {\n const n = perDoc\n ? 1\n : typeof snap?.docChanges === \"function\"\n ? snap.docChanges().length\n : typeof snap?.size === \"number\"\n ? snap.size\n : 1;\n if (n > 0) meterCount(n, hint);\n } catch {\n /* best-effort */\n }\n return onNext(snap);\n };\n }\n return real.apply(this, args);\n };\n };\n patchOnSnapshot(Query?.prototype, false);\n patchOnSnapshot(DocumentReference?.prototype, true);\n}\n\n/** Test-only: reset the install guard so a suite can re-patch fresh prototypes. */\nexport function __resetFirestoreMeterForTests(): void {\n installed = false;\n}\n","/**\n * @cross-deck/buckets — know exactly what every database read costs you, and who\n * caused it. A tiny, never-throws collector for Firestore.\n *\n * The whole footprint a consumer sees:\n * 1. init({ apiKey, firestore }) — configure once, install the trap once\n * 2. bucket(name, fn) — name the read paths that matter\n * 3. (the dashboard shows the rest — and names the ones you haven't yet)\n */\nimport { configureMeter, type MeterConfig } from \"./cost-meter\";\nimport { ReportSink, type Sink } from \"./sink\";\nimport { installFirestoreMeter, type FirestoreClasses } from \"./adapters/firestore\";\n\nexport interface InitOptions {\n /** The project's `cd_sk_` SECRET key. Server-to-server only — never a browser key. */\n apiKey: string;\n /**\n * Pass the namespace from `firebase-admin/firestore` to auto-install the read\n * trap (recommended — this is what makes every read count with no per-call work).\n * Omit it if you'd rather call `installFirestoreMeter()` yourself, or you only\n * use the manual `recordReads()` recorders.\n */\n firestore?: FirestoreClasses;\n /** Override the report endpoint (defaults to Crossdeck's ingest). */\n endpoint?: string;\n /** How often to flush coalesced counts (ms). Default 60_000. */\n flushIntervalMs?: number;\n /** Bring your own sink (self-host the rollups). Defaults to reporting up to Crossdeck. */\n sink?: Sink;\n /** Notified when a flush fails, so a dropped window is never silent. Best-effort. */\n onError?: MeterConfig[\"onError\"];\n}\n\n/**\n * Configure Buckets once, at process start. Points the meter at a sink (Crossdeck's\n * ingest by default) and — if you pass `firestore` — installs the universal read\n * trap so every read counts automatically.\n */\nexport function init(options: InitOptions): void {\n const sink = options.sink ?? new ReportSink({ apiKey: options.apiKey, endpoint: options.endpoint });\n configureMeter({ sink, flushIntervalMs: options.flushIntervalMs, onError: options.onError });\n if (options.firestore) installFirestoreMeter(options.firestore);\n}\n\n/** Alias — reads well next to `bucket()` at a call site. */\nexport { init as initBuckets };\n\n// The headline verb + the lower-level tag controls it is sugar over.\nexport {\n bucket,\n runWithCostTag,\n enterCostTag,\n refineCostTag,\n currentCostTag,\n type CostTag,\n} from \"./cost-context\";\n\n// Recorders. `record(resource, quantity)` is the generic adapter primitive — count\n// any resource unit (a future adapter records \"clickhouse.query_ms\"); recordReads/\n// Writes/Deletes are the Firestore conveniences over it.\nexport {\n record,\n recordReads,\n recordWrites,\n recordDeletes,\n flush,\n type CostHint,\n type ResourceUnit,\n type OpType,\n type MeterConfig,\n} from \"./cost-meter\";\n\n// The trap (the only datastore adapter today) + its class shape.\nexport { installFirestoreMeter, type FirestoreClasses } from \"./adapters/firestore\";\n\n// The sink seam — for self-hosting rollups instead of reporting to Crossdeck.\nexport {\n ReportSink,\n type Sink,\n type BucketsReport,\n type ResourceCounts,\n type OpCounts,\n type ReportSinkConfig,\n} from \"./sink\";\n"]}
@@ -34,9 +34,12 @@ interface BucketsReport {
34
34
  /** UTC day "YYYY-MM-DD". */
35
35
  date: string;
36
36
  /** bucket name → counts. The heart of the report. */
37
- byLabel: Record<string, OpCounts>;
37
+ byLabel: Record<string, ResourceCounts>;
38
38
  /** UTC hour "HH" → counts, for the hourly "did my fix land this hour?" view. */
39
- byHour?: Record<string, OpCounts>;
39
+ byHour?: Record<string, ResourceCounts>;
40
+ /** UTC 5-minute slot "HHMM" (slot start) → counts. The fine grain for fast
41
+ * verification against a provider console's "last hour" view. */
42
+ byMinute?: Record<string, ResourceCounts>;
40
43
  }
41
44
  /**
42
45
  * A destination for coalesced rollups. `flush` MAY throw on failure — the meter
@@ -34,9 +34,12 @@ interface BucketsReport {
34
34
  /** UTC day "YYYY-MM-DD". */
35
35
  date: string;
36
36
  /** bucket name → counts. The heart of the report. */
37
- byLabel: Record<string, OpCounts>;
37
+ byLabel: Record<string, ResourceCounts>;
38
38
  /** UTC hour "HH" → counts, for the hourly "did my fix land this hour?" view. */
39
- byHour?: Record<string, OpCounts>;
39
+ byHour?: Record<string, ResourceCounts>;
40
+ /** UTC 5-minute slot "HHMM" (slot start) → counts. The fine grain for fast
41
+ * verification against a provider console's "last hour" view. */
42
+ byMinute?: Record<string, ResourceCounts>;
40
43
  }
41
44
  /**
42
45
  * A destination for coalesced rollups. `flush` MAY throw on failure — the meter
package/dist/web.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { S as Sink, B as BucketsReport } from './sink-Cojdm73l.mjs';
2
- export { O as OpCounts } from './sink-Cojdm73l.mjs';
1
+ import { S as Sink, B as BucketsReport } from './sink-DDQmzqm5.mjs';
2
+ export { O as OpCounts } from './sink-DDQmzqm5.mjs';
3
3
 
4
4
  /**
5
5
  * web/meter — the browser read meter. Same contract as the Node meter (count in
package/dist/web.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { S as Sink, B as BucketsReport } from './sink-Cojdm73l.js';
2
- export { O as OpCounts } from './sink-Cojdm73l.js';
1
+ import { S as Sink, B as BucketsReport } from './sink-DDQmzqm5.js';
2
+ export { O as OpCounts } from './sink-DDQmzqm5.js';
3
3
 
4
4
  /**
5
5
  * web/meter — the browser read meter. Same contract as the Node meter (count in
package/dist/web.js CHANGED
@@ -26,6 +26,7 @@ var FS__namespace = /*#__PURE__*/_interopNamespace(FS);
26
26
  var SEP = "";
27
27
  var labelBuffer = /* @__PURE__ */ new Map();
28
28
  var hourBuffer = /* @__PURE__ */ new Map();
29
+ var minuteBuffer = /* @__PURE__ */ new Map();
29
30
  var sink = null;
30
31
  var flushIntervalMs = 6e4;
31
32
  var onError = null;
@@ -40,6 +41,10 @@ function configureWebMeter(config) {
40
41
  }
41
42
  var utcDate = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
42
43
  var utcHour = () => (/* @__PURE__ */ new Date()).toISOString().slice(11, 13);
44
+ var utcFiveMin = () => {
45
+ const iso = (/* @__PURE__ */ new Date()).toISOString();
46
+ return iso.slice(11, 13) + String(Math.floor(Number(iso.slice(14, 16)) / 5) * 5).padStart(2, "0");
47
+ };
43
48
  function ensureLoop() {
44
49
  if (timer) return;
45
50
  timer = setInterval(() => void flushWeb(), flushIntervalMs);
@@ -59,8 +64,10 @@ function recordWeb(op, n, label) {
59
64
  labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + n);
60
65
  const hk = date + SEP + op + SEP + utcHour();
61
66
  hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + n);
67
+ const mk = date + SEP + op + SEP + utcFiveMin();
68
+ minuteBuffer.set(mk, (minuteBuffer.get(mk) ?? 0) + n);
62
69
  ensureLoop();
63
- if (labelBuffer.size + hourBuffer.size > MAX_BUFFER_KEYS) void flushWeb();
70
+ if (labelBuffer.size + hourBuffer.size + minuteBuffer.size > MAX_BUFFER_KEYS) void flushWeb();
64
71
  } catch {
65
72
  }
66
73
  }
@@ -73,20 +80,23 @@ async function flushWeb() {
73
80
  if (!sink) {
74
81
  labelBuffer.clear();
75
82
  hourBuffer.clear();
83
+ minuteBuffer.clear();
76
84
  return;
77
85
  }
78
- if (labelBuffer.size === 0 && hourBuffer.size === 0) return;
86
+ if (labelBuffer.size === 0 && hourBuffer.size === 0 && minuteBuffer.size === 0) return;
79
87
  flushing = true;
80
88
  const labels = new Map(labelBuffer);
81
89
  const hours = new Map(hourBuffer);
90
+ const minutes = new Map(minuteBuffer);
82
91
  labelBuffer.clear();
83
92
  hourBuffer.clear();
93
+ minuteBuffer.clear();
84
94
  try {
85
95
  const byDate = /* @__PURE__ */ new Map();
86
96
  const reportFor = (date) => {
87
97
  let r = byDate.get(date);
88
98
  if (!r) {
89
- r = { date, byLabel: {}, byHour: {} };
99
+ r = { date, byLabel: {}, byHour: {}, byMinute: {} };
90
100
  byDate.set(date, r);
91
101
  }
92
102
  return r;
@@ -99,6 +109,10 @@ async function flushWeb() {
99
109
  const [date, op, hour] = k.split(SEP);
100
110
  add(reportFor(date).byHour, hour, op, n);
101
111
  }
112
+ for (const [k, n] of minutes) {
113
+ const [date, op, slot] = k.split(SEP);
114
+ add(reportFor(date).byMinute, slot, op, n);
115
+ }
102
116
  for (const report of byDate.values()) {
103
117
  await sink.flush(report);
104
118
  }
package/dist/web.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/web/meter.ts","../src/web/sink.ts","../src/web/context.ts","../src/web/firestore.ts","../src/web/index.ts"],"names":["FS","segs","getDoc","getDocs","onSnapshot","getDocFromServer","getDocsFromServer","getDocFromCache","getDocsFromCache","getCountFromServer","getAggregateFromServer","sink"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;AAmBA,IAAM,GAAA,GAAM,GAAA;AAGZ,IAAM,WAAA,uBAAkB,GAAA,EAAoB;AAE5C,IAAM,UAAA,uBAAiB,GAAA,EAAoB;AAE3C,IAAI,IAAA,GAAoB,IAAA;AACxB,IAAI,eAAA,GAAkB,GAAA;AACtB,IAAI,OAAA,GAAyC,IAAA;AAC7C,IAAI,KAAA,GAA+C,IAAA;AACnD,IAAI,QAAA,GAAW,KAAA;AACf,IAAI,cAAA,GAAiB,KAAA;AACrB,IAAM,eAAA,GAAkB,GAAA;AAQjB,SAAS,kBAAkB,MAAA,EAA8B;AAC9D,EAAA,IAAA,GAAO,MAAA,CAAO,IAAA;AACd,EAAA,IAAI,OAAO,eAAA,IAAmB,MAAA,CAAO,eAAA,GAAkB,CAAA,oBAAqB,MAAA,CAAO,eAAA;AACnF,EAAA,OAAA,GAAU,OAAO,OAAA,IAAW,IAAA;AAC9B;AAEA,IAAM,OAAA,GAAU,uBAAc,IAAI,IAAA,IAAO,WAAA,EAAY,CAAE,KAAA,CAAM,CAAA,EAAG,EAAE,CAAA;AAClE,IAAM,OAAA,GAAU,uBAAc,IAAI,IAAA,IAAO,WAAA,EAAY,CAAE,KAAA,CAAM,EAAA,EAAI,EAAE,CAAA;AAEnE,SAAS,UAAA,GAAmB;AAC1B,EAAA,IAAI,KAAA,EAAO;AACX,EAAA,KAAA,GAAQ,WAAA,CAAY,MAAM,KAAK,QAAA,IAAY,eAAe,CAAA;AAC1D,EAAA,IAAI,CAAC,cAAA,IAAkB,OAAO,gBAAA,KAAqB,UAAA,EAAY;AAC7D,IAAA,cAAA,GAAiB,IAAA;AAGjB,IAAA,gBAAA,CAAiB,oBAAoB,MAAM;AACzC,MAAA,IAAI,OAAO,QAAA,KAAa,WAAA,IAAe,SAAS,eAAA,KAAoB,QAAA,OAAe,QAAA,EAAS;AAAA,IAC9F,CAAC,CAAA;AACD,IAAA,gBAAA,CAAiB,UAAA,EAAY,MAAM,KAAK,QAAA,EAAU,CAAA;AAAA,EACpD;AACF;AAGO,SAAS,SAAA,CAAU,EAAA,EAAY,CAAA,EAAW,KAAA,EAAqB;AACpE,EAAA,IAAI;AACF,IAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,CAAC,CAAA,IAAK,KAAK,CAAA,EAAG;AACnC,IAAA,MAAM,OAAO,OAAA,EAAQ;AACrB,IAAA,MAAM,EAAA,GAAK,IAAA,GAAO,GAAA,GAAM,EAAA,GAAK,GAAA,GAAM,KAAA;AACnC,IAAA,WAAA,CAAY,IAAI,EAAA,EAAA,CAAK,WAAA,CAAY,IAAI,EAAE,CAAA,IAAK,KAAK,CAAC,CAAA;AAClD,IAAA,MAAM,EAAA,GAAK,IAAA,GAAO,GAAA,GAAM,EAAA,GAAK,MAAM,OAAA,EAAQ;AAC3C,IAAA,UAAA,CAAW,IAAI,EAAA,EAAA,CAAK,UAAA,CAAW,IAAI,EAAE,CAAA,IAAK,KAAK,CAAC,CAAA;AAChD,IAAA,UAAA,EAAW;AACX,IAAA,IAAI,YAAY,IAAA,GAAO,UAAA,CAAW,IAAA,GAAO,eAAA,OAAsB,QAAA,EAAS;AAAA,EAC1E,CAAA,CAAA,MAAQ;AAAA,EAER;AACF;AAEA,SAAS,GAAA,CAAI,MAAA,EAAkC,GAAA,EAAa,EAAA,EAAY,CAAA,EAAiB;AACvF,EAAA,MAAM,GAAA,GAAO,MAAA,CAAO,GAAG,CAAA,KAAM,EAAC;AAC9B,EAAA,GAAA,CAAI,EAAE,CAAA,GAAA,CAAK,GAAA,CAAI,EAAE,KAAK,CAAA,IAAK,CAAA;AAC7B;AAGA,eAAsB,QAAA,GAA0B;AAC9C,EAAA,IAAI,QAAA,EAAU;AACd,EAAA,IAAI,CAAC,IAAA,EAAM;AACT,IAAA,WAAA,CAAY,KAAA,EAAM;AAClB,IAAA,UAAA,CAAW,KAAA,EAAM;AACjB,IAAA;AAAA,EACF;AACA,EAAA,IAAI,WAAA,CAAY,IAAA,KAAS,CAAA,IAAK,UAAA,CAAW,SAAS,CAAA,EAAG;AACrD,EAAA,QAAA,GAAW,IAAA;AAEX,EAAA,MAAM,MAAA,GAAS,IAAI,GAAA,CAAI,WAAW,CAAA;AAClC,EAAA,MAAM,KAAA,GAAQ,IAAI,GAAA,CAAI,UAAU,CAAA;AAChC,EAAA,WAAA,CAAY,KAAA,EAAM;AAClB,EAAA,UAAA,CAAW,KAAA,EAAM;AAEjB,EAAA,IAAI;AACF,IAAA,MAAM,MAAA,uBAAa,GAAA,EAA2B;AAC9C,IAAA,MAAM,SAAA,GAAY,CAAC,IAAA,KAAgC;AACjD,MAAA,IAAI,CAAA,GAAI,MAAA,CAAO,GAAA,CAAI,IAAI,CAAA;AACvB,MAAA,IAAI,CAAC,CAAA,EAAG;AACN,QAAA,CAAA,GAAI,EAAE,IAAA,EAAM,OAAA,EAAS,EAAC,EAAG,MAAA,EAAQ,EAAC,EAAE;AACpC,QAAA,MAAA,CAAO,GAAA,CAAI,MAAM,CAAC,CAAA;AAAA,MACpB;AACA,MAAA,OAAO,CAAA;AAAA,IACT,CAAA;AACA,IAAA,KAAA,MAAW,CAAC,CAAA,EAAG,CAAC,CAAA,IAAK,MAAA,EAAQ;AAC3B,MAAA,MAAM,CAAC,IAAA,EAAM,EAAA,EAAI,KAAK,CAAA,GAAI,CAAA,CAAE,MAAM,GAAG,CAAA;AACrC,MAAA,GAAA,CAAI,UAAU,IAAI,CAAA,CAAE,OAAA,EAAS,KAAA,EAAO,IAAI,CAAC,CAAA;AAAA,IAC3C;AACA,IAAA,KAAA,MAAW,CAAC,CAAA,EAAG,CAAC,CAAA,IAAK,KAAA,EAAO;AAC1B,MAAA,MAAM,CAAC,IAAA,EAAM,EAAA,EAAI,IAAI,CAAA,GAAI,CAAA,CAAE,MAAM,GAAG,CAAA;AACpC,MAAA,GAAA,CAAI,UAAU,IAAI,CAAA,CAAE,MAAA,EAAS,IAAA,EAAM,IAAI,CAAC,CAAA;AAAA,IAC1C;AACA,IAAA,KAAA,MAAW,MAAA,IAAU,MAAA,CAAO,MAAA,EAAO,EAAG;AACpC,MAAA,MAAM,IAAA,CAAK,MAAM,MAAM,CAAA;AAAA,IACzB;AAAA,EACF,SAAS,CAAA,EAAG;AACV,IAAA,OAAA,GAAU,CAAC,CAAA;AAAA,EACb,CAAA,SAAE;AACA,IAAA,QAAA,GAAW,KAAA;AAAA,EACb;AACF;;;AChHA,IAAM,gBAAA,GAAmB,8CAAA;AAQlB,IAAM,gBAAN,MAAoC;AAAA,EACxB,QAAA;AAAA,EACA,MAAA;AAAA,EAEjB,YAAY,MAAA,EAA6B;AACvC,IAAA,IAAA,CAAK,QAAA,GAAW,OAAO,QAAA,IAAY,gBAAA;AACnC,IAAA,IAAA,CAAK,SAAS,MAAA,CAAO,MAAA;AAAA,EACvB;AAAA,EAEA,MAAM,MAAM,MAAA,EAAsC;AAChD,IAAA,MAAM,GAAA,GAAM,MAAM,KAAA,CAAM,IAAA,CAAK,QAAA,EAAU;AAAA,MACrC,MAAA,EAAQ,MAAA;AAAA,MACR,SAAA,EAAW,IAAA;AAAA,MACX,OAAA,EAAS;AAAA,QACP,cAAA,EAAgB,kBAAA;AAAA,QAChB,aAAA,EAAe,CAAA,OAAA,EAAU,IAAA,CAAK,MAAM,CAAA;AAAA,OACtC;AAAA,MACA,IAAA,EAAM,IAAA,CAAK,SAAA,CAAU,MAAM;AAAA,KAC5B,CAAA;AACD,IAAA,IAAI,GAAA,CAAI,WAAW,GAAA,EAAK;AACtB,MAAA,MAAM,IAAI,KAAA,CAAM,CAAA,kCAAA,EAAqC,GAAA,CAAI,MAAM,CAAA,CAAE,CAAA;AAAA,IACnE;AAAA,EACF;AACF;;;ACjCA,IAAI,OAAA;AAGG,SAAS,YAAA,GAAmC;AACjD,EAAA,OAAO,OAAA;AACT;AAQO,SAAS,MAAA,CAAU,MAAc,EAAA,EAAgB;AACtD,EAAA,MAAM,IAAA,GAAO,OAAA;AACb,EAAA,OAAA,GAAU,IAAA;AACV,EAAA,IAAI;AACF,IAAA,OAAO,EAAA,EAAG;AAAA,EACZ,CAAA,SAAE;AACA,IAAA,OAAA,GAAU,IAAA;AAAA,EACZ;AACF;ACJA,IAAM,SAAA,GAAeA,aAAA,CAAA,MAAA;AACrB,IAAM,UAAA,GAAgBA,aAAA,CAAA,OAAA;AACtB,IAAM,aAAA,GAAmBA,aAAA,CAAA,UAAA;AACzB,IAAM,mBAAA,GAAkCA,aAAA,CAAA,gBAAA;AACxC,IAAM,oBAAA,GAAmCA,aAAA,CAAA,iBAAA;AACzC,IAAM,kBAAA,GAAiCA,aAAA,CAAA,eAAA;AACvC,IAAM,mBAAA,GAAkCA,aAAA,CAAA,gBAAA;AACxC,IAAM,qBAAA,GAAoCA,aAAA,CAAA,kBAAA;AAC1C,IAAM,yBAAA,GAAwCA,aAAA,CAAA,sBAAA;AAG9C,SAAS,UAAU,GAAA,EAAkB;AACnC,EAAA,IAAI;AACF,IAAA,MAAM,IAAA,GACH,OAAO,GAAA,EAAK,IAAA,KAAS,YAAY,GAAA,CAAI,IAAA,KACrC,GAAA,EAAK,MAAA,EAAQ,IAAA,EAAM,QAAA,EAAU,IAAA,GAAO,GAAG,KAAK,EAAA,CAAA,IAC7C,EAAA;AACF,IAAA,IAAI,IAAA,EAAM;AACR,MAAA,MAAMC,QAAO,IAAA,CAAK,KAAA,CAAM,GAAG,CAAA,CAAE,OAAO,OAAO,CAAA;AAE3C,MAAA,MAAM,IAAA,GAAOA,KAAAA,CAAK,MAAA,GAAS,CAAA,KAAM,CAAA,GAAIA,KAAAA,CAAKA,KAAAA,CAAK,MAAA,GAAS,CAAC,CAAA,GAAIA,KAAAA,CAAKA,KAAAA,CAAK,SAAS,CAAC,CAAA;AACjF,MAAA,OAAO,IAAA,GAAO,CAAA,IAAA,EAAO,IAAI,CAAA,CAAA,GAAK,eAAA;AAAA,IAChC;AACA,IAAA,MAAM,IAAA,GAAO,GAAA,EAAK,MAAA,EAAQ,IAAA,EAAM,QAAA;AAChC,IAAA,IAAI,KAAA,CAAM,OAAA,CAAQ,IAAI,CAAA,IAAK,IAAA,CAAK,MAAA,EAAQ,OAAO,CAAA,IAAA,EAAO,IAAA,CAAK,IAAA,CAAK,MAAA,GAAS,CAAC,CAAC,CAAA,CAAA;AAAA,EAC7E,CAAA,CAAA,MAAQ;AAAA,EAER;AACA,EAAA,OAAO,eAAA;AACT;AAEA,SAAS,KAAA,CAAM,OAAe,CAAA,EAAiB;AAC7C,EAAA,IAAI;AACF,IAAA,SAAA,CAAU,MAAA,EAAQ,GAAG,KAAK,CAAA;AAAA,EAC5B,CAAA,CAAA,MAAQ;AAAA,EAER;AACF;AAGA,SAAS,UAAU,IAAA,EAAmB;AACpC,EAAA,IAAI;AACF,IAAA,IAAI,OAAO,IAAA,EAAM,UAAA,KAAe,YAAY,OAAO,IAAA,CAAK,YAAW,CAAE,MAAA;AAAA,EACvE,CAAA,CAAA,MAAQ;AAAA,EAER;AACA,EAAA,OAAO,CAAA;AACT;AAEO,SAASC,OAAAA,CAAO,QAAa,IAAA,EAA2B;AAC7D,EAAA,MAAM,KAAA,GAAQ,YAAA,EAAa,IAAK,SAAA,CAAU,GAAG,CAAA;AAC7C,EAAA,OAAO,UAAU,GAAA,EAAK,GAAG,IAAI,CAAA,CAAE,IAAA,CAAK,CAAC,IAAA,KAAc;AACjD,IAAA,KAAA,CAAM,OAAO,CAAC,CAAA;AACd,IAAA,OAAO,IAAA;AAAA,EACT,CAAC,CAAA;AACH;AAEO,SAASC,QAAAA,CAAQ,UAAe,IAAA,EAA2B;AAChE,EAAA,MAAM,KAAA,GAAQ,YAAA,EAAa,IAAK,SAAA,CAAU,KAAK,CAAA;AAC/C,EAAA,OAAO,WAAW,KAAA,EAAO,GAAG,IAAI,CAAA,CAAE,IAAA,CAAK,CAAC,IAAA,KAAc;AACpD,IAAA,KAAA,CAAM,KAAA,EAAO,OAAO,IAAA,EAAM,IAAA,KAAS,QAAA,GAAW,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,IAAA,EAAM,CAAC,CAAA,GAAI,CAAC,CAAA;AACxE,IAAA,OAAO,IAAA;AAAA,EACT,CAAC,CAAA;AACH;AAQO,SAASC,WAAAA,CAAW,QAAa,IAAA,EAAkB;AACxD,EAAA,MAAM,KAAA,GAAQ,YAAA,EAAa,IAAK,SAAA,CAAU,GAAG,CAAA;AAC7C,EAAA,MAAM,WAAW,CAAC,EAAA,KAChB,OAAO,EAAA,KAAO,UAAA,GACV,CAAC,IAAA,KAAc;AACb,IAAA,KAAA,CAAM,KAAA,EAAO,SAAA,CAAU,IAAI,CAAC,CAAA;AAC5B,IAAA,OAAO,GAAG,IAAI,CAAA;AAAA,EAChB,CAAA,GACA,EAAA;AAEN,EAAA,MAAM,GAAA,GAAM,KAAK,KAAA,EAAM;AAEvB,EAAA,IAAI,CAAA,GAAI,CAAA;AACR,EAAA,IAAI,GAAA,CAAI,CAAC,CAAA,IAAK,OAAO,GAAA,CAAI,CAAC,CAAA,KAAM,UAAA,IAAc,EAAE,MAAA,IAAU,GAAA,CAAI,CAAC,IAAI,CAAA,GAAI,CAAA;AAEvE,EAAA,IAAI,GAAA,CAAI,CAAC,CAAA,IAAK,OAAO,GAAA,CAAI,CAAC,CAAA,KAAM,QAAA,IAAY,MAAA,IAAU,GAAA,CAAI,CAAC,CAAA,EAAG;AAE5D,IAAA,GAAA,CAAI,CAAC,CAAA,GAAI,EAAE,GAAG,GAAA,CAAI,CAAC,CAAA,EAAG,IAAA,EAAM,QAAA,CAAS,GAAA,CAAI,CAAC,CAAA,CAAE,IAAI,CAAA,EAAE;AAAA,EACpD,CAAA,MAAA,IAAW,OAAO,GAAA,CAAI,CAAC,MAAM,UAAA,EAAY;AAEvC,IAAA,GAAA,CAAI,CAAC,CAAA,GAAI,QAAA,CAAS,GAAA,CAAI,CAAC,CAAC,CAAA;AAAA,EAC1B;AAEA,EAAA,OAAO,aAAA,CAAc,GAAA,EAAK,GAAG,GAAG,CAAA;AAClC;AAGO,SAASC,iBAAAA,CAAiB,QAAa,IAAA,EAA2B;AACvE,EAAA,MAAM,KAAA,GAAQ,YAAA,EAAa,IAAK,SAAA,CAAU,GAAG,CAAA;AAC7C,EAAA,OAAO,oBAAqB,GAAA,EAAK,GAAG,IAAI,CAAA,CAAE,IAAA,CAAK,CAAC,IAAA,KAAc;AAC5D,IAAA,KAAA,CAAM,OAAO,CAAC,CAAA;AACd,IAAA,OAAO,IAAA;AAAA,EACT,CAAC,CAAA;AACH;AACO,SAASC,kBAAAA,CAAkB,UAAe,IAAA,EAA2B;AAC1E,EAAA,MAAM,KAAA,GAAQ,YAAA,EAAa,IAAK,SAAA,CAAU,KAAK,CAAA;AAC/C,EAAA,OAAO,qBAAsB,KAAA,EAAO,GAAG,IAAI,CAAA,CAAE,IAAA,CAAK,CAAC,IAAA,KAAc;AAC/D,IAAA,KAAA,CAAM,KAAA,EAAO,OAAO,IAAA,EAAM,IAAA,KAAS,QAAA,GAAW,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,IAAA,EAAM,CAAC,CAAA,GAAI,CAAC,CAAA;AACxE,IAAA,OAAO,IAAA;AAAA,EACT,CAAC,CAAA;AACH;AAIO,SAASC,gBAAAA,CAAgB,QAAa,IAAA,EAA2B;AACtE,EAAA,OAAO,kBAAA,CAAoB,GAAA,EAAK,GAAG,IAAI,CAAA;AACzC;AACO,SAASC,iBAAAA,CAAiB,UAAe,IAAA,EAA2B;AACzE,EAAA,OAAO,mBAAA,CAAqB,KAAA,EAAO,GAAG,IAAI,CAAA;AAC5C;AAKA,SAAS,SAAS,IAAA,EAAmB;AACnC,EAAA,MAAM,KAAA,GAAQ,OAAO,IAAA,EAAM,IAAA,IAAO,EAAG,UAAU,QAAA,GAAW,IAAA,CAAK,IAAA,EAAK,CAAE,KAAA,GAAQ,CAAA;AAC9E,EAAA,OAAO,KAAK,GAAA,CAAI,CAAA,EAAG,KAAK,IAAA,CAAK,KAAA,GAAQ,GAAI,CAAC,CAAA;AAC5C;AACO,SAASC,mBAAAA,CAAmB,UAAe,IAAA,EAA2B;AAC3E,EAAA,MAAM,KAAA,GAAQ,YAAA,EAAa,IAAK,SAAA,CAAU,KAAK,CAAA;AAC/C,EAAA,OAAO,sBAAuB,KAAA,EAAO,GAAG,IAAI,CAAA,CAAE,IAAA,CAAK,CAAC,IAAA,KAAc;AAChE,IAAA,KAAA,CAAM,KAAA,EAAO,QAAA,CAAS,IAAI,CAAC,CAAA;AAC3B,IAAA,OAAO,IAAA;AAAA,EACT,CAAC,CAAA;AACH;AACO,SAASC,uBAAAA,CAAuB,KAAA,EAAY,IAAA,EAAA,GAAc,IAAA,EAA2B;AAC1F,EAAA,MAAM,KAAA,GAAQ,YAAA,EAAa,IAAK,SAAA,CAAU,KAAK,CAAA;AAC/C,EAAA,OAAO,yBAAA,CAA2B,OAAO,IAAA,EAAM,GAAG,IAAI,CAAA,CAAE,IAAA,CAAK,CAAC,IAAA,KAAc;AAC1E,IAAA,KAAA,CAAM,KAAA,EAAO,QAAA,CAAS,IAAI,CAAC,CAAA;AAC3B,IAAA,OAAO,IAAA;AAAA,EACT,CAAC,CAAA;AACH;;;ACxIO,SAAS,eAAe,OAAA,EAA+B;AAC5D,EAAA,MAAMC,KAAAA,GAAO,IAAI,aAAA,CAAc,EAAE,MAAA,EAAQ,QAAQ,MAAA,EAAQ,QAAA,EAAU,OAAA,CAAQ,QAAA,EAAU,CAAA;AACrF,EAAA,iBAAA,CAAkB,EAAE,MAAAA,KAAAA,EAAM,eAAA,EAAiB,QAAQ,eAAA,EAAiB,OAAA,EAAS,OAAA,CAAQ,OAAA,EAAS,CAAA;AAChG","file":"web.js","sourcesContent":["/**\n * web/meter — the browser read meter. Same contract as the Node meter (count in\n * memory, flush ~1/min, never throw into the app), adapted to the browser:\n *\n * - no AsyncLocalStorage — labels come from web/context (synchronous),\n * - \"shutdown\" is the tab going hidden/closed, so we also flush on\n * visibilitychange→hidden and pagehide (a `fetch(..., {keepalive:true})`\n * survives the unload),\n * - it talks to a Sink exactly like the Node meter, so the wire shape is\n * identical and the same ingest receives both.\n */\nimport type { Sink, BucketsReport, OpCounts } from \"../sink\";\n\n// A resource unit. The browser Firestore adapter records \"read\"; kept generic so\n// each resource stays distinct and is never merged with another.\nexport type OpType = string;\n\n// ASCII Unit Separator — a bucket/collection name never contains it, so the\n// composite key splits back cleanly.\nconst SEP = \"\\u001f\"; // ASCII Unit Separator\n\n/** key = date <US> op <US> label → count */\nconst labelBuffer = new Map<string, number>();\n/** key = date <US> op <US> hour → count */\nconst hourBuffer = new Map<string, number>();\n\nlet sink: Sink | null = null;\nlet flushIntervalMs = 60_000;\nlet onError: ((e: unknown) => void) | null = null;\nlet timer: ReturnType<typeof setInterval> | null = null;\nlet flushing = false;\nlet lifecycleBound = false;\nconst MAX_BUFFER_KEYS = 5_000;\n\nexport interface WebMeterConfig {\n sink: Sink;\n flushIntervalMs?: number;\n onError?: (e: unknown) => void;\n}\n\nexport function configureWebMeter(config: WebMeterConfig): void {\n sink = config.sink;\n if (config.flushIntervalMs && config.flushIntervalMs > 0) flushIntervalMs = config.flushIntervalMs;\n onError = config.onError ?? null;\n}\n\nconst utcDate = (): string => new Date().toISOString().slice(0, 10);\nconst utcHour = (): string => new Date().toISOString().slice(11, 13);\n\nfunction ensureLoop(): void {\n if (timer) return;\n timer = setInterval(() => void flushWeb(), flushIntervalMs);\n if (!lifecycleBound && typeof addEventListener === \"function\") {\n lifecycleBound = true;\n // The tab being hidden or torn down is the browser's \"shutdown\" — flush the\n // last window. keepalive on the sink's fetch lets it complete during unload.\n addEventListener(\"visibilitychange\", () => {\n if (typeof document !== \"undefined\" && document.visibilityState === \"hidden\") void flushWeb();\n });\n addEventListener(\"pagehide\", () => void flushWeb());\n }\n}\n\n/** Count `n` ops of `op` against `label`. Never throws. */\nexport function recordWeb(op: OpType, n: number, label: string): void {\n try {\n if (!Number.isFinite(n) || n <= 0) return;\n const date = utcDate();\n const lk = date + SEP + op + SEP + label;\n labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + n);\n const hk = date + SEP + op + SEP + utcHour();\n hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + n);\n ensureLoop();\n if (labelBuffer.size + hourBuffer.size > MAX_BUFFER_KEYS) void flushWeb();\n } catch {\n /* metering is best-effort — never disturb the page */\n }\n}\n\nfunction add(target: Record<string, OpCounts>, key: string, op: OpType, n: number): void {\n const bag = (target[key] ??= {});\n bag[op] = (bag[op] ?? 0) + n;\n}\n\n/** Coalesce the buffer into one report per UTC day and hand each to the Sink. */\nexport async function flushWeb(): Promise<void> {\n if (flushing) return;\n if (!sink) {\n labelBuffer.clear();\n hourBuffer.clear();\n return;\n }\n if (labelBuffer.size === 0 && hourBuffer.size === 0) return;\n flushing = true;\n\n const labels = new Map(labelBuffer);\n const hours = new Map(hourBuffer);\n labelBuffer.clear();\n hourBuffer.clear();\n\n try {\n const byDate = new Map<string, BucketsReport>();\n const reportFor = (date: string): BucketsReport => {\n let r = byDate.get(date);\n if (!r) {\n r = { date, byLabel: {}, byHour: {} };\n byDate.set(date, r);\n }\n return r;\n };\n for (const [k, n] of labels) {\n const [date, op, label] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byLabel, label, op, n);\n }\n for (const [k, n] of hours) {\n const [date, op, hour] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byHour!, hour, op, n);\n }\n for (const report of byDate.values()) {\n await sink.flush(report);\n }\n } catch (e) {\n onError?.(e);\n } finally {\n flushing = false;\n }\n}\n","/**\n * web/sink — reports the browser's coalesced rollup up to Crossdeck's ingest.\n *\n * Two differences from the Node sink, both forced by the browser:\n * - it authenticates with a PUBLISHABLE key (`cd_pub_live_`), never a secret — a\n * secret key cannot live in client code. (The ingest accepts publishable keys\n * for Buckets reports the same way the analytics SDK accepts them for events.)\n * - it uses `fetch(..., { keepalive: true })` so a report fired as the tab is\n * closing still completes.\n *\n * It performs ZERO database operations — it sends a summary, it does not read.\n */\nimport type { BucketsReport, Sink } from \"../sink\";\n\nconst DEFAULT_ENDPOINT = \"https://api.cross-deck.com/v1/buckets/report\";\n\nexport interface WebReportSinkConfig {\n /** The project's `cd_pub_live_` PUBLISHABLE key. */\n apiKey: string;\n endpoint?: string;\n}\n\nexport class WebReportSink implements Sink {\n private readonly endpoint: string;\n private readonly apiKey: string;\n\n constructor(config: WebReportSinkConfig) {\n this.endpoint = config.endpoint ?? DEFAULT_ENDPOINT;\n this.apiKey = config.apiKey;\n }\n\n async flush(report: BucketsReport): Promise<void> {\n const res = await fetch(this.endpoint, {\n method: \"POST\",\n keepalive: true,\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${this.apiKey}`,\n },\n body: JSON.stringify(report),\n });\n if (res.status !== 202) {\n throw new Error(`Buckets web report rejected: HTTP ${res.status}`);\n }\n }\n}\n","/**\n * web/context — the browser's tagging primitive.\n *\n * The Node collector rides AsyncLocalStorage to attribute reads to a bucket\n * across async fan-outs. The browser has no AsyncLocalStorage — but it doesn't\n * need one: a read (a `getDocs` call, an `onSnapshot` registration) is set up\n * SYNCHRONOUSLY, so a plain module-level \"current label\" captured at call time is\n * exact. `bucket(name, fn)` sets it for the synchronous body of `fn` and restores\n * it after — so the read inside picks up the name, and an `onSnapshot` listener\n * keeps that name for every future fire.\n */\n\nlet current: string | undefined;\n\n/** The bucket name in effect right now, or undefined (→ cascade to collection). */\nexport function currentLabel(): string | undefined {\n return current;\n}\n\n/**\n * Attribute every read SET UP inside `fn` to the bucket `name`:\n *\n * bucket(\"pulse-map\", () => onSnapshot(liveQuery, render));\n * // → that listener's reads all show as \"pulse-map\", forever\n */\nexport function bucket<T>(name: string, fn: () => T): T {\n const prev = current;\n current = name;\n try {\n return fn();\n } finally {\n current = prev;\n }\n}\n","/**\n * web/firestore — drop-in wrappers for the three Firestore client read calls.\n *\n * Swap your import source and nothing else:\n * - import { getDoc, getDocs, onSnapshot } from \"firebase/firestore\"\n * + import { getDoc, getDocs, onSnapshot } from \"@cross-deck/buckets/web\"\n *\n * Each wrapper calls the REAL Firestore function, counts the documents it\n * delivers (exactly what Firestore bills), labels it (your `bucket()` name, else\n * the collection), and returns the real result untouched. It can never change a\n * result or throw from the metering — same safety contract as the server trap.\n *\n * COUNTING:\n * - getDoc → 1 read\n * - getDocs → snapshot.size reads\n * - onSnapshot → on EVERY fire, the number of doc changes delivered\n * (first fire = all matching docs; each update = just the changed ones —\n * which is precisely what a listener is billed).\n */\nimport * as FS from \"firebase/firestore\";\nimport { recordWeb } from \"./meter\";\nimport { currentLabel } from \"./context\";\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\ntype AnyAsync = (...args: any[]) => Promise<any>;\n\n// Call the real reads through the namespace + loose aliases: a function missing on\n// an older `firebase` (e.g. getAggregateFromServer pre-v10) is simply `undefined`\n// rather than a hard import error, and the wrappers pass arguments through verbatim.\nconst rawGetDoc = FS.getDoc as AnyAsync;\nconst rawGetDocs = FS.getDocs as AnyAsync;\nconst rawOnSnapshot = FS.onSnapshot as (...args: any[]) => any;\nconst rawGetDocFromServer = (FS as any).getDocFromServer as AnyAsync | undefined;\nconst rawGetDocsFromServer = (FS as any).getDocsFromServer as AnyAsync | undefined;\nconst rawGetDocFromCache = (FS as any).getDocFromCache as AnyAsync | undefined;\nconst rawGetDocsFromCache = (FS as any).getDocsFromCache as AnyAsync | undefined;\nconst rawGetCountFromServer = (FS as any).getCountFromServer as AnyAsync | undefined;\nconst rawGetAggregateFromServer = (FS as any).getAggregateFromServer as AnyAsync | undefined;\n\n/** Best-effort collection label from a ref/query. PURE; never throws. */\nfunction collLabel(ref: any): string {\n try {\n const path: string =\n (typeof ref?.path === \"string\" && ref.path) ||\n (ref?._query?.path?.segments?.join?.(\"/\") ?? \"\") ||\n \"\";\n if (path) {\n const segs = path.split(\"/\").filter(Boolean);\n // even segment count → document path (…/coll/id); odd → collection.\n const coll = segs.length % 2 === 0 ? segs[segs.length - 2] : segs[segs.length - 1];\n return coll ? `col:${coll}` : \"uncategorized\";\n }\n const segs = ref?._query?.path?.segments;\n if (Array.isArray(segs) && segs.length) return `col:${segs[segs.length - 1]}`;\n } catch {\n /* never throw from labelling */\n }\n return \"uncategorized\";\n}\n\nfunction meter(label: string, n: number): void {\n try {\n recordWeb(\"read\", n, label);\n } catch {\n /* best-effort */\n }\n}\n\n/** Count the docs a snapshot delivers: a query's changed docs, or 1 for a doc. */\nfunction countSnap(snap: any): number {\n try {\n if (typeof snap?.docChanges === \"function\") return snap.docChanges().length;\n } catch {\n /* fall through */\n }\n return 1;\n}\n\nexport function getDoc(ref: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(ref);\n return rawGetDoc(ref, ...rest).then((snap: any) => {\n meter(label, 1);\n return snap;\n });\n}\n\nexport function getDocs(query: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(query);\n return rawGetDocs(query, ...rest).then((snap: any) => {\n meter(label, typeof snap?.size === \"number\" ? Math.max(snap.size, 1) : 1);\n return snap;\n });\n}\n\n/**\n * onSnapshot has several overloads — (ref, observer), (ref, onNext, onError,\n * onComplete), and either of those with a leading SnapshotListenOptions. We find\n * the next-handler wherever it is (a function or `observer.next`) and wrap it to\n * count on each fire, leaving every other argument exactly as passed.\n */\nexport function onSnapshot(ref: any, ...args: any[]): any {\n const label = currentLabel() ?? collLabel(ref);\n const wrapNext = (fn: any) =>\n typeof fn === \"function\"\n ? (snap: any) => {\n meter(label, countSnap(snap));\n return fn(snap);\n }\n : fn;\n\n const out = args.slice();\n // Leading options object (not a function, not an observer): skip it.\n let i = 0;\n if (out[0] && typeof out[0] !== \"function\" && !(\"next\" in out[0])) i = 1;\n\n if (out[i] && typeof out[i] === \"object\" && \"next\" in out[i]) {\n // observer form: clone with wrapped next\n out[i] = { ...out[i], next: wrapNext(out[i].next) };\n } else if (typeof out[i] === \"function\") {\n // callback form: wrap onNext (the first function)\n out[i] = wrapNext(out[i]);\n }\n\n return rawOnSnapshot(ref, ...out);\n}\n\n// ── Force-from-server reads — bill exactly like getDoc / getDocs ──────────────\nexport function getDocFromServer(ref: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(ref);\n return rawGetDocFromServer!(ref, ...rest).then((snap: any) => {\n meter(label, 1);\n return snap;\n });\n}\nexport function getDocsFromServer(query: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(query);\n return rawGetDocsFromServer!(query, ...rest).then((snap: any) => {\n meter(label, typeof snap?.size === \"number\" ? Math.max(snap.size, 1) : 1);\n return snap;\n });\n}\n\n// ── Cache-only reads — NOT billed, so pass through and count NOTHING ──────────\n// (Counting these would over-report; a cache hit costs you nothing.)\nexport function getDocFromCache(ref: any, ...rest: any[]): Promise<any> {\n return rawGetDocFromCache!(ref, ...rest);\n}\nexport function getDocsFromCache(query: any, ...rest: any[]): Promise<any> {\n return rawGetDocsFromCache!(query, ...rest);\n}\n\n// ── Aggregation — count() / sum() / average() ────────────────────────────────\n// Honest estimate: ceil(count / 1000), min 1 — Firestore never exposes the exact\n// index-entry count. Same model as the server trap, so the numbers reconcile.\nfunction aggReads(snap: any): number {\n const count = typeof snap?.data?.()?.count === \"number\" ? snap.data().count : 0;\n return Math.max(1, Math.ceil(count / 1000));\n}\nexport function getCountFromServer(query: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(query);\n return rawGetCountFromServer!(query, ...rest).then((snap: any) => {\n meter(label, aggReads(snap));\n return snap;\n });\n}\nexport function getAggregateFromServer(query: any, spec: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(query);\n return rawGetAggregateFromServer!(query, spec, ...rest).then((snap: any) => {\n meter(label, aggReads(snap));\n return snap;\n });\n}\n","/**\n * @cross-deck/buckets/web — the BROWSER collector.\n *\n * Most Firebase apps read straight from the browser (live `onSnapshot`\n * listeners, `getDocs`, `getDoc`) — reads billed to your project that a\n * server-side collector can never see. This adapter closes that hole.\n *\n * Setup (two lines + one import swap):\n *\n * import { initBucketsWeb, bucket } from \"@cross-deck/buckets/web\";\n * import { getDoc, getDocs, onSnapshot } from \"@cross-deck/buckets/web\"; // was \"firebase/firestore\"\n *\n * initBucketsWeb({ apiKey: \"cd_pub_live_…\" }); // your PUBLISHABLE key\n *\n * bucket(\"pulse-map\", () => onSnapshot(liveQuery, render));\n *\n * Every read those wrappers see is counted, labelled, and reported up the same\n * ingest pipe as the server collector — so the dashboard shows server AND browser\n * reads side by side.\n */\nimport { configureWebMeter, flushWeb, type WebMeterConfig } from \"./meter\";\nimport { WebReportSink } from \"./sink\";\n\nexport interface InitWebOptions {\n /** The project's `cd_pub_live_` PUBLISHABLE key (safe in client code). */\n apiKey: string;\n /** Override the report endpoint (defaults to Crossdeck's ingest). */\n endpoint?: string;\n /** How often to flush coalesced counts (ms). Default 60_000. */\n flushIntervalMs?: number;\n /** Notified when a flush fails, so a dropped window is never silent. */\n onError?: WebMeterConfig[\"onError\"];\n}\n\n/** Configure the browser collector once, at app start. */\nexport function initBucketsWeb(options: InitWebOptions): void {\n const sink = new WebReportSink({ apiKey: options.apiKey, endpoint: options.endpoint });\n configureWebMeter({ sink, flushIntervalMs: options.flushIntervalMs, onError: options.onError });\n}\n\n// The tagging verb + the metered read wrappers (swap your firebase/firestore\n// import for these). Cache-only reads pass through uncounted (they aren't billed).\nexport { bucket } from \"./context\";\nexport {\n getDoc,\n getDocs,\n onSnapshot,\n getDocFromServer,\n getDocsFromServer,\n getDocFromCache,\n getDocsFromCache,\n getCountFromServer,\n getAggregateFromServer,\n} from \"./firestore\";\nexport { flushWeb as flush } from \"./meter\";\n\n// The sink seam — for self-hosting the browser rollups instead of reporting to Crossdeck.\nexport { WebReportSink, type WebReportSinkConfig } from \"./sink\";\nexport type { BucketsReport, OpCounts, Sink } from \"../sink\";\n"]}
1
+ {"version":3,"sources":["../src/web/meter.ts","../src/web/sink.ts","../src/web/context.ts","../src/web/firestore.ts","../src/web/index.ts"],"names":["FS","segs","getDoc","getDocs","onSnapshot","getDocFromServer","getDocsFromServer","getDocFromCache","getDocsFromCache","getCountFromServer","getAggregateFromServer","sink"],"mappings":"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","file":"web.js","sourcesContent":["/**\n * web/meter — the browser read meter. Same contract as the Node meter (count in\n * memory, flush ~1/min, never throw into the app), adapted to the browser:\n *\n * - no AsyncLocalStorage — labels come from web/context (synchronous),\n * - \"shutdown\" is the tab going hidden/closed, so we also flush on\n * visibilitychange→hidden and pagehide (a `fetch(..., {keepalive:true})`\n * survives the unload),\n * - it talks to a Sink exactly like the Node meter, so the wire shape is\n * identical and the same ingest receives both.\n */\nimport type { Sink, BucketsReport, OpCounts } from \"../sink\";\n\n// A resource unit. The browser Firestore adapter records \"read\"; kept generic so\n// each resource stays distinct and is never merged with another.\nexport type OpType = string;\n\n// ASCII Unit Separator — a bucket/collection name never contains it, so the\n// composite key splits back cleanly.\nconst SEP = \"\\u001f\"; // ASCII Unit Separator\n\n/** key = date <US> op <US> label → count */\nconst labelBuffer = new Map<string, number>();\n/** key = date <US> op <US> hour → count */\nconst hourBuffer = new Map<string, number>();\n/** key = date <US> op <US> 5-min-slot (\"HHMM\") → count — fast-verification grain. */\nconst minuteBuffer = new Map<string, number>();\n\nlet sink: Sink | null = null;\nlet flushIntervalMs = 60_000;\nlet onError: ((e: unknown) => void) | null = null;\nlet timer: ReturnType<typeof setInterval> | null = null;\nlet flushing = false;\nlet lifecycleBound = false;\nconst MAX_BUFFER_KEYS = 5_000;\n\nexport interface WebMeterConfig {\n sink: Sink;\n flushIntervalMs?: number;\n onError?: (e: unknown) => void;\n}\n\nexport function configureWebMeter(config: WebMeterConfig): void {\n sink = config.sink;\n if (config.flushIntervalMs && config.flushIntervalMs > 0) flushIntervalMs = config.flushIntervalMs;\n onError = config.onError ?? null;\n}\n\nconst utcDate = (): string => new Date().toISOString().slice(0, 10);\nconst utcHour = (): string => new Date().toISOString().slice(11, 13);\nconst utcFiveMin = (): string => {\n const iso = new Date().toISOString();\n return iso.slice(11, 13) + String(Math.floor(Number(iso.slice(14, 16)) / 5) * 5).padStart(2, \"0\");\n};\n\nfunction ensureLoop(): void {\n if (timer) return;\n timer = setInterval(() => void flushWeb(), flushIntervalMs);\n if (!lifecycleBound && typeof addEventListener === \"function\") {\n lifecycleBound = true;\n // The tab being hidden or torn down is the browser's \"shutdown\" — flush the\n // last window. keepalive on the sink's fetch lets it complete during unload.\n addEventListener(\"visibilitychange\", () => {\n if (typeof document !== \"undefined\" && document.visibilityState === \"hidden\") void flushWeb();\n });\n addEventListener(\"pagehide\", () => void flushWeb());\n }\n}\n\n/** Count `n` ops of `op` against `label`. Never throws. */\nexport function recordWeb(op: OpType, n: number, label: string): void {\n try {\n if (!Number.isFinite(n) || n <= 0) return;\n const date = utcDate();\n const lk = date + SEP + op + SEP + label;\n labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + n);\n const hk = date + SEP + op + SEP + utcHour();\n hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + n);\n const mk = date + SEP + op + SEP + utcFiveMin();\n minuteBuffer.set(mk, (minuteBuffer.get(mk) ?? 0) + n);\n ensureLoop();\n if (labelBuffer.size + hourBuffer.size + minuteBuffer.size > MAX_BUFFER_KEYS) void flushWeb();\n } catch {\n /* metering is best-effort — never disturb the page */\n }\n}\n\nfunction add(target: Record<string, OpCounts>, key: string, op: OpType, n: number): void {\n const bag = (target[key] ??= {});\n bag[op] = (bag[op] ?? 0) + n;\n}\n\n/** Coalesce the buffer into one report per UTC day and hand each to the Sink. */\nexport async function flushWeb(): Promise<void> {\n if (flushing) return;\n if (!sink) {\n labelBuffer.clear();\n hourBuffer.clear();\n minuteBuffer.clear();\n return;\n }\n if (labelBuffer.size === 0 && hourBuffer.size === 0 && minuteBuffer.size === 0) return;\n flushing = true;\n\n const labels = new Map(labelBuffer);\n const hours = new Map(hourBuffer);\n const minutes = new Map(minuteBuffer);\n labelBuffer.clear();\n hourBuffer.clear();\n minuteBuffer.clear();\n\n try {\n const byDate = new Map<string, BucketsReport>();\n const reportFor = (date: string): BucketsReport => {\n let r = byDate.get(date);\n if (!r) {\n r = { date, byLabel: {}, byHour: {}, byMinute: {} };\n byDate.set(date, r);\n }\n return r;\n };\n for (const [k, n] of labels) {\n const [date, op, label] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byLabel, label, op, n);\n }\n for (const [k, n] of hours) {\n const [date, op, hour] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byHour!, hour, op, n);\n }\n for (const [k, n] of minutes) {\n const [date, op, slot] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byMinute!, slot, op, n);\n }\n for (const report of byDate.values()) {\n await sink.flush(report);\n }\n } catch (e) {\n onError?.(e);\n } finally {\n flushing = false;\n }\n}\n","/**\n * web/sink — reports the browser's coalesced rollup up to Crossdeck's ingest.\n *\n * Two differences from the Node sink, both forced by the browser:\n * - it authenticates with a PUBLISHABLE key (`cd_pub_live_`), never a secret — a\n * secret key cannot live in client code. (The ingest accepts publishable keys\n * for Buckets reports the same way the analytics SDK accepts them for events.)\n * - it uses `fetch(..., { keepalive: true })` so a report fired as the tab is\n * closing still completes.\n *\n * It performs ZERO database operations — it sends a summary, it does not read.\n */\nimport type { BucketsReport, Sink } from \"../sink\";\n\nconst DEFAULT_ENDPOINT = \"https://api.cross-deck.com/v1/buckets/report\";\n\nexport interface WebReportSinkConfig {\n /** The project's `cd_pub_live_` PUBLISHABLE key. */\n apiKey: string;\n endpoint?: string;\n}\n\nexport class WebReportSink implements Sink {\n private readonly endpoint: string;\n private readonly apiKey: string;\n\n constructor(config: WebReportSinkConfig) {\n this.endpoint = config.endpoint ?? DEFAULT_ENDPOINT;\n this.apiKey = config.apiKey;\n }\n\n async flush(report: BucketsReport): Promise<void> {\n const res = await fetch(this.endpoint, {\n method: \"POST\",\n keepalive: true,\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${this.apiKey}`,\n },\n body: JSON.stringify(report),\n });\n if (res.status !== 202) {\n throw new Error(`Buckets web report rejected: HTTP ${res.status}`);\n }\n }\n}\n","/**\n * web/context — the browser's tagging primitive.\n *\n * The Node collector rides AsyncLocalStorage to attribute reads to a bucket\n * across async fan-outs. The browser has no AsyncLocalStorage — but it doesn't\n * need one: a read (a `getDocs` call, an `onSnapshot` registration) is set up\n * SYNCHRONOUSLY, so a plain module-level \"current label\" captured at call time is\n * exact. `bucket(name, fn)` sets it for the synchronous body of `fn` and restores\n * it after — so the read inside picks up the name, and an `onSnapshot` listener\n * keeps that name for every future fire.\n */\n\nlet current: string | undefined;\n\n/** The bucket name in effect right now, or undefined (→ cascade to collection). */\nexport function currentLabel(): string | undefined {\n return current;\n}\n\n/**\n * Attribute every read SET UP inside `fn` to the bucket `name`:\n *\n * bucket(\"pulse-map\", () => onSnapshot(liveQuery, render));\n * // → that listener's reads all show as \"pulse-map\", forever\n */\nexport function bucket<T>(name: string, fn: () => T): T {\n const prev = current;\n current = name;\n try {\n return fn();\n } finally {\n current = prev;\n }\n}\n","/**\n * web/firestore — drop-in wrappers for the three Firestore client read calls.\n *\n * Swap your import source and nothing else:\n * - import { getDoc, getDocs, onSnapshot } from \"firebase/firestore\"\n * + import { getDoc, getDocs, onSnapshot } from \"@cross-deck/buckets/web\"\n *\n * Each wrapper calls the REAL Firestore function, counts the documents it\n * delivers (exactly what Firestore bills), labels it (your `bucket()` name, else\n * the collection), and returns the real result untouched. It can never change a\n * result or throw from the metering — same safety contract as the server trap.\n *\n * COUNTING:\n * - getDoc → 1 read\n * - getDocs → snapshot.size reads\n * - onSnapshot → on EVERY fire, the number of doc changes delivered\n * (first fire = all matching docs; each update = just the changed ones —\n * which is precisely what a listener is billed).\n */\nimport * as FS from \"firebase/firestore\";\nimport { recordWeb } from \"./meter\";\nimport { currentLabel } from \"./context\";\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\ntype AnyAsync = (...args: any[]) => Promise<any>;\n\n// Call the real reads through the namespace + loose aliases: a function missing on\n// an older `firebase` (e.g. getAggregateFromServer pre-v10) is simply `undefined`\n// rather than a hard import error, and the wrappers pass arguments through verbatim.\nconst rawGetDoc = FS.getDoc as AnyAsync;\nconst rawGetDocs = FS.getDocs as AnyAsync;\nconst rawOnSnapshot = FS.onSnapshot as (...args: any[]) => any;\nconst rawGetDocFromServer = (FS as any).getDocFromServer as AnyAsync | undefined;\nconst rawGetDocsFromServer = (FS as any).getDocsFromServer as AnyAsync | undefined;\nconst rawGetDocFromCache = (FS as any).getDocFromCache as AnyAsync | undefined;\nconst rawGetDocsFromCache = (FS as any).getDocsFromCache as AnyAsync | undefined;\nconst rawGetCountFromServer = (FS as any).getCountFromServer as AnyAsync | undefined;\nconst rawGetAggregateFromServer = (FS as any).getAggregateFromServer as AnyAsync | undefined;\n\n/** Best-effort collection label from a ref/query. PURE; never throws. */\nfunction collLabel(ref: any): string {\n try {\n const path: string =\n (typeof ref?.path === \"string\" && ref.path) ||\n (ref?._query?.path?.segments?.join?.(\"/\") ?? \"\") ||\n \"\";\n if (path) {\n const segs = path.split(\"/\").filter(Boolean);\n // even segment count → document path (…/coll/id); odd → collection.\n const coll = segs.length % 2 === 0 ? segs[segs.length - 2] : segs[segs.length - 1];\n return coll ? `col:${coll}` : \"uncategorized\";\n }\n const segs = ref?._query?.path?.segments;\n if (Array.isArray(segs) && segs.length) return `col:${segs[segs.length - 1]}`;\n } catch {\n /* never throw from labelling */\n }\n return \"uncategorized\";\n}\n\nfunction meter(label: string, n: number): void {\n try {\n recordWeb(\"read\", n, label);\n } catch {\n /* best-effort */\n }\n}\n\n/** Count the docs a snapshot delivers: a query's changed docs, or 1 for a doc. */\nfunction countSnap(snap: any): number {\n try {\n if (typeof snap?.docChanges === \"function\") return snap.docChanges().length;\n } catch {\n /* fall through */\n }\n return 1;\n}\n\nexport function getDoc(ref: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(ref);\n return rawGetDoc(ref, ...rest).then((snap: any) => {\n meter(label, 1);\n return snap;\n });\n}\n\nexport function getDocs(query: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(query);\n return rawGetDocs(query, ...rest).then((snap: any) => {\n meter(label, typeof snap?.size === \"number\" ? Math.max(snap.size, 1) : 1);\n return snap;\n });\n}\n\n/**\n * onSnapshot has several overloads — (ref, observer), (ref, onNext, onError,\n * onComplete), and either of those with a leading SnapshotListenOptions. We find\n * the next-handler wherever it is (a function or `observer.next`) and wrap it to\n * count on each fire, leaving every other argument exactly as passed.\n */\nexport function onSnapshot(ref: any, ...args: any[]): any {\n const label = currentLabel() ?? collLabel(ref);\n const wrapNext = (fn: any) =>\n typeof fn === \"function\"\n ? (snap: any) => {\n meter(label, countSnap(snap));\n return fn(snap);\n }\n : fn;\n\n const out = args.slice();\n // Leading options object (not a function, not an observer): skip it.\n let i = 0;\n if (out[0] && typeof out[0] !== \"function\" && !(\"next\" in out[0])) i = 1;\n\n if (out[i] && typeof out[i] === \"object\" && \"next\" in out[i]) {\n // observer form: clone with wrapped next\n out[i] = { ...out[i], next: wrapNext(out[i].next) };\n } else if (typeof out[i] === \"function\") {\n // callback form: wrap onNext (the first function)\n out[i] = wrapNext(out[i]);\n }\n\n return rawOnSnapshot(ref, ...out);\n}\n\n// ── Force-from-server reads — bill exactly like getDoc / getDocs ──────────────\nexport function getDocFromServer(ref: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(ref);\n return rawGetDocFromServer!(ref, ...rest).then((snap: any) => {\n meter(label, 1);\n return snap;\n });\n}\nexport function getDocsFromServer(query: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(query);\n return rawGetDocsFromServer!(query, ...rest).then((snap: any) => {\n meter(label, typeof snap?.size === \"number\" ? Math.max(snap.size, 1) : 1);\n return snap;\n });\n}\n\n// ── Cache-only reads — NOT billed, so pass through and count NOTHING ──────────\n// (Counting these would over-report; a cache hit costs you nothing.)\nexport function getDocFromCache(ref: any, ...rest: any[]): Promise<any> {\n return rawGetDocFromCache!(ref, ...rest);\n}\nexport function getDocsFromCache(query: any, ...rest: any[]): Promise<any> {\n return rawGetDocsFromCache!(query, ...rest);\n}\n\n// ── Aggregation — count() / sum() / average() ────────────────────────────────\n// Honest estimate: ceil(count / 1000), min 1 — Firestore never exposes the exact\n// index-entry count. Same model as the server trap, so the numbers reconcile.\nfunction aggReads(snap: any): number {\n const count = typeof snap?.data?.()?.count === \"number\" ? snap.data().count : 0;\n return Math.max(1, Math.ceil(count / 1000));\n}\nexport function getCountFromServer(query: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(query);\n return rawGetCountFromServer!(query, ...rest).then((snap: any) => {\n meter(label, aggReads(snap));\n return snap;\n });\n}\nexport function getAggregateFromServer(query: any, spec: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(query);\n return rawGetAggregateFromServer!(query, spec, ...rest).then((snap: any) => {\n meter(label, aggReads(snap));\n return snap;\n });\n}\n","/**\n * @cross-deck/buckets/web — the BROWSER collector.\n *\n * Most Firebase apps read straight from the browser (live `onSnapshot`\n * listeners, `getDocs`, `getDoc`) — reads billed to your project that a\n * server-side collector can never see. This adapter closes that hole.\n *\n * Setup (two lines + one import swap):\n *\n * import { initBucketsWeb, bucket } from \"@cross-deck/buckets/web\";\n * import { getDoc, getDocs, onSnapshot } from \"@cross-deck/buckets/web\"; // was \"firebase/firestore\"\n *\n * initBucketsWeb({ apiKey: \"cd_pub_live_…\" }); // your PUBLISHABLE key\n *\n * bucket(\"pulse-map\", () => onSnapshot(liveQuery, render));\n *\n * Every read those wrappers see is counted, labelled, and reported up the same\n * ingest pipe as the server collector — so the dashboard shows server AND browser\n * reads side by side.\n */\nimport { configureWebMeter, flushWeb, type WebMeterConfig } from \"./meter\";\nimport { WebReportSink } from \"./sink\";\n\nexport interface InitWebOptions {\n /** The project's `cd_pub_live_` PUBLISHABLE key (safe in client code). */\n apiKey: string;\n /** Override the report endpoint (defaults to Crossdeck's ingest). */\n endpoint?: string;\n /** How often to flush coalesced counts (ms). Default 60_000. */\n flushIntervalMs?: number;\n /** Notified when a flush fails, so a dropped window is never silent. */\n onError?: WebMeterConfig[\"onError\"];\n}\n\n/** Configure the browser collector once, at app start. */\nexport function initBucketsWeb(options: InitWebOptions): void {\n const sink = new WebReportSink({ apiKey: options.apiKey, endpoint: options.endpoint });\n configureWebMeter({ sink, flushIntervalMs: options.flushIntervalMs, onError: options.onError });\n}\n\n// The tagging verb + the metered read wrappers (swap your firebase/firestore\n// import for these). Cache-only reads pass through uncounted (they aren't billed).\nexport { bucket } from \"./context\";\nexport {\n getDoc,\n getDocs,\n onSnapshot,\n getDocFromServer,\n getDocsFromServer,\n getDocFromCache,\n getDocsFromCache,\n getCountFromServer,\n getAggregateFromServer,\n} from \"./firestore\";\nexport { flushWeb as flush } from \"./meter\";\n\n// The sink seam — for self-hosting the browser rollups instead of reporting to Crossdeck.\nexport { WebReportSink, type WebReportSinkConfig } from \"./sink\";\nexport type { BucketsReport, OpCounts, Sink } from \"../sink\";\n"]}
package/dist/web.mjs CHANGED
@@ -4,6 +4,7 @@ import * as FS from 'firebase/firestore';
4
4
  var SEP = "";
5
5
  var labelBuffer = /* @__PURE__ */ new Map();
6
6
  var hourBuffer = /* @__PURE__ */ new Map();
7
+ var minuteBuffer = /* @__PURE__ */ new Map();
7
8
  var sink = null;
8
9
  var flushIntervalMs = 6e4;
9
10
  var onError = null;
@@ -18,6 +19,10 @@ function configureWebMeter(config) {
18
19
  }
19
20
  var utcDate = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
20
21
  var utcHour = () => (/* @__PURE__ */ new Date()).toISOString().slice(11, 13);
22
+ var utcFiveMin = () => {
23
+ const iso = (/* @__PURE__ */ new Date()).toISOString();
24
+ return iso.slice(11, 13) + String(Math.floor(Number(iso.slice(14, 16)) / 5) * 5).padStart(2, "0");
25
+ };
21
26
  function ensureLoop() {
22
27
  if (timer) return;
23
28
  timer = setInterval(() => void flushWeb(), flushIntervalMs);
@@ -37,8 +42,10 @@ function recordWeb(op, n, label) {
37
42
  labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + n);
38
43
  const hk = date + SEP + op + SEP + utcHour();
39
44
  hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + n);
45
+ const mk = date + SEP + op + SEP + utcFiveMin();
46
+ minuteBuffer.set(mk, (minuteBuffer.get(mk) ?? 0) + n);
40
47
  ensureLoop();
41
- if (labelBuffer.size + hourBuffer.size > MAX_BUFFER_KEYS) void flushWeb();
48
+ if (labelBuffer.size + hourBuffer.size + minuteBuffer.size > MAX_BUFFER_KEYS) void flushWeb();
42
49
  } catch {
43
50
  }
44
51
  }
@@ -51,20 +58,23 @@ async function flushWeb() {
51
58
  if (!sink) {
52
59
  labelBuffer.clear();
53
60
  hourBuffer.clear();
61
+ minuteBuffer.clear();
54
62
  return;
55
63
  }
56
- if (labelBuffer.size === 0 && hourBuffer.size === 0) return;
64
+ if (labelBuffer.size === 0 && hourBuffer.size === 0 && minuteBuffer.size === 0) return;
57
65
  flushing = true;
58
66
  const labels = new Map(labelBuffer);
59
67
  const hours = new Map(hourBuffer);
68
+ const minutes = new Map(minuteBuffer);
60
69
  labelBuffer.clear();
61
70
  hourBuffer.clear();
71
+ minuteBuffer.clear();
62
72
  try {
63
73
  const byDate = /* @__PURE__ */ new Map();
64
74
  const reportFor = (date) => {
65
75
  let r = byDate.get(date);
66
76
  if (!r) {
67
- r = { date, byLabel: {}, byHour: {} };
77
+ r = { date, byLabel: {}, byHour: {}, byMinute: {} };
68
78
  byDate.set(date, r);
69
79
  }
70
80
  return r;
@@ -77,6 +87,10 @@ async function flushWeb() {
77
87
  const [date, op, hour] = k.split(SEP);
78
88
  add(reportFor(date).byHour, hour, op, n);
79
89
  }
90
+ for (const [k, n] of minutes) {
91
+ const [date, op, slot] = k.split(SEP);
92
+ add(reportFor(date).byMinute, slot, op, n);
93
+ }
80
94
  for (const report of byDate.values()) {
81
95
  await sink.flush(report);
82
96
  }
package/dist/web.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/web/meter.ts","../src/web/sink.ts","../src/web/context.ts","../src/web/firestore.ts","../src/web/index.ts"],"names":["segs","getDoc","getDocs","onSnapshot","getDocFromServer","getDocsFromServer","getDocFromCache","getDocsFromCache","getCountFromServer","getAggregateFromServer","sink"],"mappings":"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","file":"web.mjs","sourcesContent":["/**\n * web/meter — the browser read meter. Same contract as the Node meter (count in\n * memory, flush ~1/min, never throw into the app), adapted to the browser:\n *\n * - no AsyncLocalStorage — labels come from web/context (synchronous),\n * - \"shutdown\" is the tab going hidden/closed, so we also flush on\n * visibilitychange→hidden and pagehide (a `fetch(..., {keepalive:true})`\n * survives the unload),\n * - it talks to a Sink exactly like the Node meter, so the wire shape is\n * identical and the same ingest receives both.\n */\nimport type { Sink, BucketsReport, OpCounts } from \"../sink\";\n\n// A resource unit. The browser Firestore adapter records \"read\"; kept generic so\n// each resource stays distinct and is never merged with another.\nexport type OpType = string;\n\n// ASCII Unit Separator — a bucket/collection name never contains it, so the\n// composite key splits back cleanly.\nconst SEP = \"\\u001f\"; // ASCII Unit Separator\n\n/** key = date <US> op <US> label → count */\nconst labelBuffer = new Map<string, number>();\n/** key = date <US> op <US> hour → count */\nconst hourBuffer = new Map<string, number>();\n\nlet sink: Sink | null = null;\nlet flushIntervalMs = 60_000;\nlet onError: ((e: unknown) => void) | null = null;\nlet timer: ReturnType<typeof setInterval> | null = null;\nlet flushing = false;\nlet lifecycleBound = false;\nconst MAX_BUFFER_KEYS = 5_000;\n\nexport interface WebMeterConfig {\n sink: Sink;\n flushIntervalMs?: number;\n onError?: (e: unknown) => void;\n}\n\nexport function configureWebMeter(config: WebMeterConfig): void {\n sink = config.sink;\n if (config.flushIntervalMs && config.flushIntervalMs > 0) flushIntervalMs = config.flushIntervalMs;\n onError = config.onError ?? null;\n}\n\nconst utcDate = (): string => new Date().toISOString().slice(0, 10);\nconst utcHour = (): string => new Date().toISOString().slice(11, 13);\n\nfunction ensureLoop(): void {\n if (timer) return;\n timer = setInterval(() => void flushWeb(), flushIntervalMs);\n if (!lifecycleBound && typeof addEventListener === \"function\") {\n lifecycleBound = true;\n // The tab being hidden or torn down is the browser's \"shutdown\" — flush the\n // last window. keepalive on the sink's fetch lets it complete during unload.\n addEventListener(\"visibilitychange\", () => {\n if (typeof document !== \"undefined\" && document.visibilityState === \"hidden\") void flushWeb();\n });\n addEventListener(\"pagehide\", () => void flushWeb());\n }\n}\n\n/** Count `n` ops of `op` against `label`. Never throws. */\nexport function recordWeb(op: OpType, n: number, label: string): void {\n try {\n if (!Number.isFinite(n) || n <= 0) return;\n const date = utcDate();\n const lk = date + SEP + op + SEP + label;\n labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + n);\n const hk = date + SEP + op + SEP + utcHour();\n hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + n);\n ensureLoop();\n if (labelBuffer.size + hourBuffer.size > MAX_BUFFER_KEYS) void flushWeb();\n } catch {\n /* metering is best-effort — never disturb the page */\n }\n}\n\nfunction add(target: Record<string, OpCounts>, key: string, op: OpType, n: number): void {\n const bag = (target[key] ??= {});\n bag[op] = (bag[op] ?? 0) + n;\n}\n\n/** Coalesce the buffer into one report per UTC day and hand each to the Sink. */\nexport async function flushWeb(): Promise<void> {\n if (flushing) return;\n if (!sink) {\n labelBuffer.clear();\n hourBuffer.clear();\n return;\n }\n if (labelBuffer.size === 0 && hourBuffer.size === 0) return;\n flushing = true;\n\n const labels = new Map(labelBuffer);\n const hours = new Map(hourBuffer);\n labelBuffer.clear();\n hourBuffer.clear();\n\n try {\n const byDate = new Map<string, BucketsReport>();\n const reportFor = (date: string): BucketsReport => {\n let r = byDate.get(date);\n if (!r) {\n r = { date, byLabel: {}, byHour: {} };\n byDate.set(date, r);\n }\n return r;\n };\n for (const [k, n] of labels) {\n const [date, op, label] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byLabel, label, op, n);\n }\n for (const [k, n] of hours) {\n const [date, op, hour] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byHour!, hour, op, n);\n }\n for (const report of byDate.values()) {\n await sink.flush(report);\n }\n } catch (e) {\n onError?.(e);\n } finally {\n flushing = false;\n }\n}\n","/**\n * web/sink — reports the browser's coalesced rollup up to Crossdeck's ingest.\n *\n * Two differences from the Node sink, both forced by the browser:\n * - it authenticates with a PUBLISHABLE key (`cd_pub_live_`), never a secret — a\n * secret key cannot live in client code. (The ingest accepts publishable keys\n * for Buckets reports the same way the analytics SDK accepts them for events.)\n * - it uses `fetch(..., { keepalive: true })` so a report fired as the tab is\n * closing still completes.\n *\n * It performs ZERO database operations — it sends a summary, it does not read.\n */\nimport type { BucketsReport, Sink } from \"../sink\";\n\nconst DEFAULT_ENDPOINT = \"https://api.cross-deck.com/v1/buckets/report\";\n\nexport interface WebReportSinkConfig {\n /** The project's `cd_pub_live_` PUBLISHABLE key. */\n apiKey: string;\n endpoint?: string;\n}\n\nexport class WebReportSink implements Sink {\n private readonly endpoint: string;\n private readonly apiKey: string;\n\n constructor(config: WebReportSinkConfig) {\n this.endpoint = config.endpoint ?? DEFAULT_ENDPOINT;\n this.apiKey = config.apiKey;\n }\n\n async flush(report: BucketsReport): Promise<void> {\n const res = await fetch(this.endpoint, {\n method: \"POST\",\n keepalive: true,\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${this.apiKey}`,\n },\n body: JSON.stringify(report),\n });\n if (res.status !== 202) {\n throw new Error(`Buckets web report rejected: HTTP ${res.status}`);\n }\n }\n}\n","/**\n * web/context — the browser's tagging primitive.\n *\n * The Node collector rides AsyncLocalStorage to attribute reads to a bucket\n * across async fan-outs. The browser has no AsyncLocalStorage — but it doesn't\n * need one: a read (a `getDocs` call, an `onSnapshot` registration) is set up\n * SYNCHRONOUSLY, so a plain module-level \"current label\" captured at call time is\n * exact. `bucket(name, fn)` sets it for the synchronous body of `fn` and restores\n * it after — so the read inside picks up the name, and an `onSnapshot` listener\n * keeps that name for every future fire.\n */\n\nlet current: string | undefined;\n\n/** The bucket name in effect right now, or undefined (→ cascade to collection). */\nexport function currentLabel(): string | undefined {\n return current;\n}\n\n/**\n * Attribute every read SET UP inside `fn` to the bucket `name`:\n *\n * bucket(\"pulse-map\", () => onSnapshot(liveQuery, render));\n * // → that listener's reads all show as \"pulse-map\", forever\n */\nexport function bucket<T>(name: string, fn: () => T): T {\n const prev = current;\n current = name;\n try {\n return fn();\n } finally {\n current = prev;\n }\n}\n","/**\n * web/firestore — drop-in wrappers for the three Firestore client read calls.\n *\n * Swap your import source and nothing else:\n * - import { getDoc, getDocs, onSnapshot } from \"firebase/firestore\"\n * + import { getDoc, getDocs, onSnapshot } from \"@cross-deck/buckets/web\"\n *\n * Each wrapper calls the REAL Firestore function, counts the documents it\n * delivers (exactly what Firestore bills), labels it (your `bucket()` name, else\n * the collection), and returns the real result untouched. It can never change a\n * result or throw from the metering — same safety contract as the server trap.\n *\n * COUNTING:\n * - getDoc → 1 read\n * - getDocs → snapshot.size reads\n * - onSnapshot → on EVERY fire, the number of doc changes delivered\n * (first fire = all matching docs; each update = just the changed ones —\n * which is precisely what a listener is billed).\n */\nimport * as FS from \"firebase/firestore\";\nimport { recordWeb } from \"./meter\";\nimport { currentLabel } from \"./context\";\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\ntype AnyAsync = (...args: any[]) => Promise<any>;\n\n// Call the real reads through the namespace + loose aliases: a function missing on\n// an older `firebase` (e.g. getAggregateFromServer pre-v10) is simply `undefined`\n// rather than a hard import error, and the wrappers pass arguments through verbatim.\nconst rawGetDoc = FS.getDoc as AnyAsync;\nconst rawGetDocs = FS.getDocs as AnyAsync;\nconst rawOnSnapshot = FS.onSnapshot as (...args: any[]) => any;\nconst rawGetDocFromServer = (FS as any).getDocFromServer as AnyAsync | undefined;\nconst rawGetDocsFromServer = (FS as any).getDocsFromServer as AnyAsync | undefined;\nconst rawGetDocFromCache = (FS as any).getDocFromCache as AnyAsync | undefined;\nconst rawGetDocsFromCache = (FS as any).getDocsFromCache as AnyAsync | undefined;\nconst rawGetCountFromServer = (FS as any).getCountFromServer as AnyAsync | undefined;\nconst rawGetAggregateFromServer = (FS as any).getAggregateFromServer as AnyAsync | undefined;\n\n/** Best-effort collection label from a ref/query. PURE; never throws. */\nfunction collLabel(ref: any): string {\n try {\n const path: string =\n (typeof ref?.path === \"string\" && ref.path) ||\n (ref?._query?.path?.segments?.join?.(\"/\") ?? \"\") ||\n \"\";\n if (path) {\n const segs = path.split(\"/\").filter(Boolean);\n // even segment count → document path (…/coll/id); odd → collection.\n const coll = segs.length % 2 === 0 ? segs[segs.length - 2] : segs[segs.length - 1];\n return coll ? `col:${coll}` : \"uncategorized\";\n }\n const segs = ref?._query?.path?.segments;\n if (Array.isArray(segs) && segs.length) return `col:${segs[segs.length - 1]}`;\n } catch {\n /* never throw from labelling */\n }\n return \"uncategorized\";\n}\n\nfunction meter(label: string, n: number): void {\n try {\n recordWeb(\"read\", n, label);\n } catch {\n /* best-effort */\n }\n}\n\n/** Count the docs a snapshot delivers: a query's changed docs, or 1 for a doc. */\nfunction countSnap(snap: any): number {\n try {\n if (typeof snap?.docChanges === \"function\") return snap.docChanges().length;\n } catch {\n /* fall through */\n }\n return 1;\n}\n\nexport function getDoc(ref: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(ref);\n return rawGetDoc(ref, ...rest).then((snap: any) => {\n meter(label, 1);\n return snap;\n });\n}\n\nexport function getDocs(query: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(query);\n return rawGetDocs(query, ...rest).then((snap: any) => {\n meter(label, typeof snap?.size === \"number\" ? Math.max(snap.size, 1) : 1);\n return snap;\n });\n}\n\n/**\n * onSnapshot has several overloads — (ref, observer), (ref, onNext, onError,\n * onComplete), and either of those with a leading SnapshotListenOptions. We find\n * the next-handler wherever it is (a function or `observer.next`) and wrap it to\n * count on each fire, leaving every other argument exactly as passed.\n */\nexport function onSnapshot(ref: any, ...args: any[]): any {\n const label = currentLabel() ?? collLabel(ref);\n const wrapNext = (fn: any) =>\n typeof fn === \"function\"\n ? (snap: any) => {\n meter(label, countSnap(snap));\n return fn(snap);\n }\n : fn;\n\n const out = args.slice();\n // Leading options object (not a function, not an observer): skip it.\n let i = 0;\n if (out[0] && typeof out[0] !== \"function\" && !(\"next\" in out[0])) i = 1;\n\n if (out[i] && typeof out[i] === \"object\" && \"next\" in out[i]) {\n // observer form: clone with wrapped next\n out[i] = { ...out[i], next: wrapNext(out[i].next) };\n } else if (typeof out[i] === \"function\") {\n // callback form: wrap onNext (the first function)\n out[i] = wrapNext(out[i]);\n }\n\n return rawOnSnapshot(ref, ...out);\n}\n\n// ── Force-from-server reads — bill exactly like getDoc / getDocs ──────────────\nexport function getDocFromServer(ref: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(ref);\n return rawGetDocFromServer!(ref, ...rest).then((snap: any) => {\n meter(label, 1);\n return snap;\n });\n}\nexport function getDocsFromServer(query: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(query);\n return rawGetDocsFromServer!(query, ...rest).then((snap: any) => {\n meter(label, typeof snap?.size === \"number\" ? Math.max(snap.size, 1) : 1);\n return snap;\n });\n}\n\n// ── Cache-only reads — NOT billed, so pass through and count NOTHING ──────────\n// (Counting these would over-report; a cache hit costs you nothing.)\nexport function getDocFromCache(ref: any, ...rest: any[]): Promise<any> {\n return rawGetDocFromCache!(ref, ...rest);\n}\nexport function getDocsFromCache(query: any, ...rest: any[]): Promise<any> {\n return rawGetDocsFromCache!(query, ...rest);\n}\n\n// ── Aggregation — count() / sum() / average() ────────────────────────────────\n// Honest estimate: ceil(count / 1000), min 1 — Firestore never exposes the exact\n// index-entry count. Same model as the server trap, so the numbers reconcile.\nfunction aggReads(snap: any): number {\n const count = typeof snap?.data?.()?.count === \"number\" ? snap.data().count : 0;\n return Math.max(1, Math.ceil(count / 1000));\n}\nexport function getCountFromServer(query: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(query);\n return rawGetCountFromServer!(query, ...rest).then((snap: any) => {\n meter(label, aggReads(snap));\n return snap;\n });\n}\nexport function getAggregateFromServer(query: any, spec: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(query);\n return rawGetAggregateFromServer!(query, spec, ...rest).then((snap: any) => {\n meter(label, aggReads(snap));\n return snap;\n });\n}\n","/**\n * @cross-deck/buckets/web — the BROWSER collector.\n *\n * Most Firebase apps read straight from the browser (live `onSnapshot`\n * listeners, `getDocs`, `getDoc`) — reads billed to your project that a\n * server-side collector can never see. This adapter closes that hole.\n *\n * Setup (two lines + one import swap):\n *\n * import { initBucketsWeb, bucket } from \"@cross-deck/buckets/web\";\n * import { getDoc, getDocs, onSnapshot } from \"@cross-deck/buckets/web\"; // was \"firebase/firestore\"\n *\n * initBucketsWeb({ apiKey: \"cd_pub_live_…\" }); // your PUBLISHABLE key\n *\n * bucket(\"pulse-map\", () => onSnapshot(liveQuery, render));\n *\n * Every read those wrappers see is counted, labelled, and reported up the same\n * ingest pipe as the server collector — so the dashboard shows server AND browser\n * reads side by side.\n */\nimport { configureWebMeter, flushWeb, type WebMeterConfig } from \"./meter\";\nimport { WebReportSink } from \"./sink\";\n\nexport interface InitWebOptions {\n /** The project's `cd_pub_live_` PUBLISHABLE key (safe in client code). */\n apiKey: string;\n /** Override the report endpoint (defaults to Crossdeck's ingest). */\n endpoint?: string;\n /** How often to flush coalesced counts (ms). Default 60_000. */\n flushIntervalMs?: number;\n /** Notified when a flush fails, so a dropped window is never silent. */\n onError?: WebMeterConfig[\"onError\"];\n}\n\n/** Configure the browser collector once, at app start. */\nexport function initBucketsWeb(options: InitWebOptions): void {\n const sink = new WebReportSink({ apiKey: options.apiKey, endpoint: options.endpoint });\n configureWebMeter({ sink, flushIntervalMs: options.flushIntervalMs, onError: options.onError });\n}\n\n// The tagging verb + the metered read wrappers (swap your firebase/firestore\n// import for these). Cache-only reads pass through uncounted (they aren't billed).\nexport { bucket } from \"./context\";\nexport {\n getDoc,\n getDocs,\n onSnapshot,\n getDocFromServer,\n getDocsFromServer,\n getDocFromCache,\n getDocsFromCache,\n getCountFromServer,\n getAggregateFromServer,\n} from \"./firestore\";\nexport { flushWeb as flush } from \"./meter\";\n\n// The sink seam — for self-hosting the browser rollups instead of reporting to Crossdeck.\nexport { WebReportSink, type WebReportSinkConfig } from \"./sink\";\nexport type { BucketsReport, OpCounts, Sink } from \"../sink\";\n"]}
1
+ {"version":3,"sources":["../src/web/meter.ts","../src/web/sink.ts","../src/web/context.ts","../src/web/firestore.ts","../src/web/index.ts"],"names":["segs","getDoc","getDocs","onSnapshot","getDocFromServer","getDocsFromServer","getDocFromCache","getDocsFromCache","getCountFromServer","getAggregateFromServer","sink"],"mappings":";;;AAmBA,IAAM,GAAA,GAAM,GAAA;AAGZ,IAAM,WAAA,uBAAkB,GAAA,EAAoB;AAE5C,IAAM,UAAA,uBAAiB,GAAA,EAAoB;AAE3C,IAAM,YAAA,uBAAmB,GAAA,EAAoB;AAE7C,IAAI,IAAA,GAAoB,IAAA;AACxB,IAAI,eAAA,GAAkB,GAAA;AACtB,IAAI,OAAA,GAAyC,IAAA;AAC7C,IAAI,KAAA,GAA+C,IAAA;AACnD,IAAI,QAAA,GAAW,KAAA;AACf,IAAI,cAAA,GAAiB,KAAA;AACrB,IAAM,eAAA,GAAkB,GAAA;AAQjB,SAAS,kBAAkB,MAAA,EAA8B;AAC9D,EAAA,IAAA,GAAO,MAAA,CAAO,IAAA;AACd,EAAA,IAAI,OAAO,eAAA,IAAmB,MAAA,CAAO,eAAA,GAAkB,CAAA,oBAAqB,MAAA,CAAO,eAAA;AACnF,EAAA,OAAA,GAAU,OAAO,OAAA,IAAW,IAAA;AAC9B;AAEA,IAAM,OAAA,GAAU,uBAAc,IAAI,IAAA,IAAO,WAAA,EAAY,CAAE,KAAA,CAAM,CAAA,EAAG,EAAE,CAAA;AAClE,IAAM,OAAA,GAAU,uBAAc,IAAI,IAAA,IAAO,WAAA,EAAY,CAAE,KAAA,CAAM,EAAA,EAAI,EAAE,CAAA;AACnE,IAAM,aAAa,MAAc;AAC/B,EAAA,MAAM,GAAA,GAAA,iBAAM,IAAI,IAAA,EAAK,EAAE,WAAA,EAAY;AACnC,EAAA,OAAO,GAAA,CAAI,MAAM,EAAA,EAAI,EAAE,IAAI,MAAA,CAAO,IAAA,CAAK,MAAM,MAAA,CAAO,GAAA,CAAI,MAAM,EAAA,EAAI,EAAE,CAAC,CAAA,GAAI,CAAC,IAAI,CAAC,CAAA,CAAE,QAAA,CAAS,CAAA,EAAG,GAAG,CAAA;AAClG,CAAA;AAEA,SAAS,UAAA,GAAmB;AAC1B,EAAA,IAAI,KAAA,EAAO;AACX,EAAA,KAAA,GAAQ,WAAA,CAAY,MAAM,KAAK,QAAA,IAAY,eAAe,CAAA;AAC1D,EAAA,IAAI,CAAC,cAAA,IAAkB,OAAO,gBAAA,KAAqB,UAAA,EAAY;AAC7D,IAAA,cAAA,GAAiB,IAAA;AAGjB,IAAA,gBAAA,CAAiB,oBAAoB,MAAM;AACzC,MAAA,IAAI,OAAO,QAAA,KAAa,WAAA,IAAe,SAAS,eAAA,KAAoB,QAAA,OAAe,QAAA,EAAS;AAAA,IAC9F,CAAC,CAAA;AACD,IAAA,gBAAA,CAAiB,UAAA,EAAY,MAAM,KAAK,QAAA,EAAU,CAAA;AAAA,EACpD;AACF;AAGO,SAAS,SAAA,CAAU,EAAA,EAAY,CAAA,EAAW,KAAA,EAAqB;AACpE,EAAA,IAAI;AACF,IAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,CAAC,CAAA,IAAK,KAAK,CAAA,EAAG;AACnC,IAAA,MAAM,OAAO,OAAA,EAAQ;AACrB,IAAA,MAAM,EAAA,GAAK,IAAA,GAAO,GAAA,GAAM,EAAA,GAAK,GAAA,GAAM,KAAA;AACnC,IAAA,WAAA,CAAY,IAAI,EAAA,EAAA,CAAK,WAAA,CAAY,IAAI,EAAE,CAAA,IAAK,KAAK,CAAC,CAAA;AAClD,IAAA,MAAM,EAAA,GAAK,IAAA,GAAO,GAAA,GAAM,EAAA,GAAK,MAAM,OAAA,EAAQ;AAC3C,IAAA,UAAA,CAAW,IAAI,EAAA,EAAA,CAAK,UAAA,CAAW,IAAI,EAAE,CAAA,IAAK,KAAK,CAAC,CAAA;AAChD,IAAA,MAAM,EAAA,GAAK,IAAA,GAAO,GAAA,GAAM,EAAA,GAAK,MAAM,UAAA,EAAW;AAC9C,IAAA,YAAA,CAAa,IAAI,EAAA,EAAA,CAAK,YAAA,CAAa,IAAI,EAAE,CAAA,IAAK,KAAK,CAAC,CAAA;AACpD,IAAA,UAAA,EAAW;AACX,IAAA,IAAI,WAAA,CAAY,OAAO,UAAA,CAAW,IAAA,GAAO,aAAa,IAAA,GAAO,eAAA,OAAsB,QAAA,EAAS;AAAA,EAC9F,CAAA,CAAA,MAAQ;AAAA,EAER;AACF;AAEA,SAAS,GAAA,CAAI,MAAA,EAAkC,GAAA,EAAa,EAAA,EAAY,CAAA,EAAiB;AACvF,EAAA,MAAM,GAAA,GAAO,MAAA,CAAO,GAAG,CAAA,KAAM,EAAC;AAC9B,EAAA,GAAA,CAAI,EAAE,CAAA,GAAA,CAAK,GAAA,CAAI,EAAE,KAAK,CAAA,IAAK,CAAA;AAC7B;AAGA,eAAsB,QAAA,GAA0B;AAC9C,EAAA,IAAI,QAAA,EAAU;AACd,EAAA,IAAI,CAAC,IAAA,EAAM;AACT,IAAA,WAAA,CAAY,KAAA,EAAM;AAClB,IAAA,UAAA,CAAW,KAAA,EAAM;AACjB,IAAA,YAAA,CAAa,KAAA,EAAM;AACnB,IAAA;AAAA,EACF;AACA,EAAA,IAAI,WAAA,CAAY,SAAS,CAAA,IAAK,UAAA,CAAW,SAAS,CAAA,IAAK,YAAA,CAAa,SAAS,CAAA,EAAG;AAChF,EAAA,QAAA,GAAW,IAAA;AAEX,EAAA,MAAM,MAAA,GAAS,IAAI,GAAA,CAAI,WAAW,CAAA;AAClC,EAAA,MAAM,KAAA,GAAQ,IAAI,GAAA,CAAI,UAAU,CAAA;AAChC,EAAA,MAAM,OAAA,GAAU,IAAI,GAAA,CAAI,YAAY,CAAA;AACpC,EAAA,WAAA,CAAY,KAAA,EAAM;AAClB,EAAA,UAAA,CAAW,KAAA,EAAM;AACjB,EAAA,YAAA,CAAa,KAAA,EAAM;AAEnB,EAAA,IAAI;AACF,IAAA,MAAM,MAAA,uBAAa,GAAA,EAA2B;AAC9C,IAAA,MAAM,SAAA,GAAY,CAAC,IAAA,KAAgC;AACjD,MAAA,IAAI,CAAA,GAAI,MAAA,CAAO,GAAA,CAAI,IAAI,CAAA;AACvB,MAAA,IAAI,CAAC,CAAA,EAAG;AACN,QAAA,CAAA,GAAI,EAAE,IAAA,EAAM,OAAA,EAAS,EAAC,EAAG,QAAQ,EAAC,EAAG,QAAA,EAAU,EAAC,EAAE;AAClD,QAAA,MAAA,CAAO,GAAA,CAAI,MAAM,CAAC,CAAA;AAAA,MACpB;AACA,MAAA,OAAO,CAAA;AAAA,IACT,CAAA;AACA,IAAA,KAAA,MAAW,CAAC,CAAA,EAAG,CAAC,CAAA,IAAK,MAAA,EAAQ;AAC3B,MAAA,MAAM,CAAC,IAAA,EAAM,EAAA,EAAI,KAAK,CAAA,GAAI,CAAA,CAAE,MAAM,GAAG,CAAA;AACrC,MAAA,GAAA,CAAI,UAAU,IAAI,CAAA,CAAE,OAAA,EAAS,KAAA,EAAO,IAAI,CAAC,CAAA;AAAA,IAC3C;AACA,IAAA,KAAA,MAAW,CAAC,CAAA,EAAG,CAAC,CAAA,IAAK,KAAA,EAAO;AAC1B,MAAA,MAAM,CAAC,IAAA,EAAM,EAAA,EAAI,IAAI,CAAA,GAAI,CAAA,CAAE,MAAM,GAAG,CAAA;AACpC,MAAA,GAAA,CAAI,UAAU,IAAI,CAAA,CAAE,MAAA,EAAS,IAAA,EAAM,IAAI,CAAC,CAAA;AAAA,IAC1C;AACA,IAAA,KAAA,MAAW,CAAC,CAAA,EAAG,CAAC,CAAA,IAAK,OAAA,EAAS;AAC5B,MAAA,MAAM,CAAC,IAAA,EAAM,EAAA,EAAI,IAAI,CAAA,GAAI,CAAA,CAAE,MAAM,GAAG,CAAA;AACpC,MAAA,GAAA,CAAI,UAAU,IAAI,CAAA,CAAE,QAAA,EAAW,IAAA,EAAM,IAAI,CAAC,CAAA;AAAA,IAC5C;AACA,IAAA,KAAA,MAAW,MAAA,IAAU,MAAA,CAAO,MAAA,EAAO,EAAG;AACpC,MAAA,MAAM,IAAA,CAAK,MAAM,MAAM,CAAA;AAAA,IACzB;AAAA,EACF,SAAS,CAAA,EAAG;AACV,IAAA,OAAA,GAAU,CAAC,CAAA;AAAA,EACb,CAAA,SAAE;AACA,IAAA,QAAA,GAAW,KAAA;AAAA,EACb;AACF;;;AC/HA,IAAM,gBAAA,GAAmB,8CAAA;AAQlB,IAAM,gBAAN,MAAoC;AAAA,EACxB,QAAA;AAAA,EACA,MAAA;AAAA,EAEjB,YAAY,MAAA,EAA6B;AACvC,IAAA,IAAA,CAAK,QAAA,GAAW,OAAO,QAAA,IAAY,gBAAA;AACnC,IAAA,IAAA,CAAK,SAAS,MAAA,CAAO,MAAA;AAAA,EACvB;AAAA,EAEA,MAAM,MAAM,MAAA,EAAsC;AAChD,IAAA,MAAM,GAAA,GAAM,MAAM,KAAA,CAAM,IAAA,CAAK,QAAA,EAAU;AAAA,MACrC,MAAA,EAAQ,MAAA;AAAA,MACR,SAAA,EAAW,IAAA;AAAA,MACX,OAAA,EAAS;AAAA,QACP,cAAA,EAAgB,kBAAA;AAAA,QAChB,aAAA,EAAe,CAAA,OAAA,EAAU,IAAA,CAAK,MAAM,CAAA;AAAA,OACtC;AAAA,MACA,IAAA,EAAM,IAAA,CAAK,SAAA,CAAU,MAAM;AAAA,KAC5B,CAAA;AACD,IAAA,IAAI,GAAA,CAAI,WAAW,GAAA,EAAK;AACtB,MAAA,MAAM,IAAI,KAAA,CAAM,CAAA,kCAAA,EAAqC,GAAA,CAAI,MAAM,CAAA,CAAE,CAAA;AAAA,IACnE;AAAA,EACF;AACF;;;ACjCA,IAAI,OAAA;AAGG,SAAS,YAAA,GAAmC;AACjD,EAAA,OAAO,OAAA;AACT;AAQO,SAAS,MAAA,CAAU,MAAc,EAAA,EAAgB;AACtD,EAAA,MAAM,IAAA,GAAO,OAAA;AACb,EAAA,OAAA,GAAU,IAAA;AACV,EAAA,IAAI;AACF,IAAA,OAAO,EAAA,EAAG;AAAA,EACZ,CAAA,SAAE;AACA,IAAA,OAAA,GAAU,IAAA;AAAA,EACZ;AACF;ACJA,IAAM,SAAA,GAAe,EAAA,CAAA,MAAA;AACrB,IAAM,UAAA,GAAgB,EAAA,CAAA,OAAA;AACtB,IAAM,aAAA,GAAmB,EAAA,CAAA,UAAA;AACzB,IAAM,mBAAA,GAAkC,EAAA,CAAA,gBAAA;AACxC,IAAM,oBAAA,GAAmC,EAAA,CAAA,iBAAA;AACzC,IAAM,kBAAA,GAAiC,EAAA,CAAA,eAAA;AACvC,IAAM,mBAAA,GAAkC,EAAA,CAAA,gBAAA;AACxC,IAAM,qBAAA,GAAoC,EAAA,CAAA,kBAAA;AAC1C,IAAM,yBAAA,GAAwC,EAAA,CAAA,sBAAA;AAG9C,SAAS,UAAU,GAAA,EAAkB;AACnC,EAAA,IAAI;AACF,IAAA,MAAM,IAAA,GACH,OAAO,GAAA,EAAK,IAAA,KAAS,YAAY,GAAA,CAAI,IAAA,KACrC,GAAA,EAAK,MAAA,EAAQ,IAAA,EAAM,QAAA,EAAU,IAAA,GAAO,GAAG,KAAK,EAAA,CAAA,IAC7C,EAAA;AACF,IAAA,IAAI,IAAA,EAAM;AACR,MAAA,MAAMA,QAAO,IAAA,CAAK,KAAA,CAAM,GAAG,CAAA,CAAE,OAAO,OAAO,CAAA;AAE3C,MAAA,MAAM,IAAA,GAAOA,KAAAA,CAAK,MAAA,GAAS,CAAA,KAAM,CAAA,GAAIA,KAAAA,CAAKA,KAAAA,CAAK,MAAA,GAAS,CAAC,CAAA,GAAIA,KAAAA,CAAKA,KAAAA,CAAK,SAAS,CAAC,CAAA;AACjF,MAAA,OAAO,IAAA,GAAO,CAAA,IAAA,EAAO,IAAI,CAAA,CAAA,GAAK,eAAA;AAAA,IAChC;AACA,IAAA,MAAM,IAAA,GAAO,GAAA,EAAK,MAAA,EAAQ,IAAA,EAAM,QAAA;AAChC,IAAA,IAAI,KAAA,CAAM,OAAA,CAAQ,IAAI,CAAA,IAAK,IAAA,CAAK,MAAA,EAAQ,OAAO,CAAA,IAAA,EAAO,IAAA,CAAK,IAAA,CAAK,MAAA,GAAS,CAAC,CAAC,CAAA,CAAA;AAAA,EAC7E,CAAA,CAAA,MAAQ;AAAA,EAER;AACA,EAAA,OAAO,eAAA;AACT;AAEA,SAAS,KAAA,CAAM,OAAe,CAAA,EAAiB;AAC7C,EAAA,IAAI;AACF,IAAA,SAAA,CAAU,MAAA,EAAQ,GAAG,KAAK,CAAA;AAAA,EAC5B,CAAA,CAAA,MAAQ;AAAA,EAER;AACF;AAGA,SAAS,UAAU,IAAA,EAAmB;AACpC,EAAA,IAAI;AACF,IAAA,IAAI,OAAO,IAAA,EAAM,UAAA,KAAe,YAAY,OAAO,IAAA,CAAK,YAAW,CAAE,MAAA;AAAA,EACvE,CAAA,CAAA,MAAQ;AAAA,EAER;AACA,EAAA,OAAO,CAAA;AACT;AAEO,SAASC,OAAAA,CAAO,QAAa,IAAA,EAA2B;AAC7D,EAAA,MAAM,KAAA,GAAQ,YAAA,EAAa,IAAK,SAAA,CAAU,GAAG,CAAA;AAC7C,EAAA,OAAO,UAAU,GAAA,EAAK,GAAG,IAAI,CAAA,CAAE,IAAA,CAAK,CAAC,IAAA,KAAc;AACjD,IAAA,KAAA,CAAM,OAAO,CAAC,CAAA;AACd,IAAA,OAAO,IAAA;AAAA,EACT,CAAC,CAAA;AACH;AAEO,SAASC,QAAAA,CAAQ,UAAe,IAAA,EAA2B;AAChE,EAAA,MAAM,KAAA,GAAQ,YAAA,EAAa,IAAK,SAAA,CAAU,KAAK,CAAA;AAC/C,EAAA,OAAO,WAAW,KAAA,EAAO,GAAG,IAAI,CAAA,CAAE,IAAA,CAAK,CAAC,IAAA,KAAc;AACpD,IAAA,KAAA,CAAM,KAAA,EAAO,OAAO,IAAA,EAAM,IAAA,KAAS,QAAA,GAAW,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,IAAA,EAAM,CAAC,CAAA,GAAI,CAAC,CAAA;AACxE,IAAA,OAAO,IAAA;AAAA,EACT,CAAC,CAAA;AACH;AAQO,SAASC,WAAAA,CAAW,QAAa,IAAA,EAAkB;AACxD,EAAA,MAAM,KAAA,GAAQ,YAAA,EAAa,IAAK,SAAA,CAAU,GAAG,CAAA;AAC7C,EAAA,MAAM,WAAW,CAAC,EAAA,KAChB,OAAO,EAAA,KAAO,UAAA,GACV,CAAC,IAAA,KAAc;AACb,IAAA,KAAA,CAAM,KAAA,EAAO,SAAA,CAAU,IAAI,CAAC,CAAA;AAC5B,IAAA,OAAO,GAAG,IAAI,CAAA;AAAA,EAChB,CAAA,GACA,EAAA;AAEN,EAAA,MAAM,GAAA,GAAM,KAAK,KAAA,EAAM;AAEvB,EAAA,IAAI,CAAA,GAAI,CAAA;AACR,EAAA,IAAI,GAAA,CAAI,CAAC,CAAA,IAAK,OAAO,GAAA,CAAI,CAAC,CAAA,KAAM,UAAA,IAAc,EAAE,MAAA,IAAU,GAAA,CAAI,CAAC,IAAI,CAAA,GAAI,CAAA;AAEvE,EAAA,IAAI,GAAA,CAAI,CAAC,CAAA,IAAK,OAAO,GAAA,CAAI,CAAC,CAAA,KAAM,QAAA,IAAY,MAAA,IAAU,GAAA,CAAI,CAAC,CAAA,EAAG;AAE5D,IAAA,GAAA,CAAI,CAAC,CAAA,GAAI,EAAE,GAAG,GAAA,CAAI,CAAC,CAAA,EAAG,IAAA,EAAM,QAAA,CAAS,GAAA,CAAI,CAAC,CAAA,CAAE,IAAI,CAAA,EAAE;AAAA,EACpD,CAAA,MAAA,IAAW,OAAO,GAAA,CAAI,CAAC,MAAM,UAAA,EAAY;AAEvC,IAAA,GAAA,CAAI,CAAC,CAAA,GAAI,QAAA,CAAS,GAAA,CAAI,CAAC,CAAC,CAAA;AAAA,EAC1B;AAEA,EAAA,OAAO,aAAA,CAAc,GAAA,EAAK,GAAG,GAAG,CAAA;AAClC;AAGO,SAASC,iBAAAA,CAAiB,QAAa,IAAA,EAA2B;AACvE,EAAA,MAAM,KAAA,GAAQ,YAAA,EAAa,IAAK,SAAA,CAAU,GAAG,CAAA;AAC7C,EAAA,OAAO,oBAAqB,GAAA,EAAK,GAAG,IAAI,CAAA,CAAE,IAAA,CAAK,CAAC,IAAA,KAAc;AAC5D,IAAA,KAAA,CAAM,OAAO,CAAC,CAAA;AACd,IAAA,OAAO,IAAA;AAAA,EACT,CAAC,CAAA;AACH;AACO,SAASC,kBAAAA,CAAkB,UAAe,IAAA,EAA2B;AAC1E,EAAA,MAAM,KAAA,GAAQ,YAAA,EAAa,IAAK,SAAA,CAAU,KAAK,CAAA;AAC/C,EAAA,OAAO,qBAAsB,KAAA,EAAO,GAAG,IAAI,CAAA,CAAE,IAAA,CAAK,CAAC,IAAA,KAAc;AAC/D,IAAA,KAAA,CAAM,KAAA,EAAO,OAAO,IAAA,EAAM,IAAA,KAAS,QAAA,GAAW,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,IAAA,EAAM,CAAC,CAAA,GAAI,CAAC,CAAA;AACxE,IAAA,OAAO,IAAA;AAAA,EACT,CAAC,CAAA;AACH;AAIO,SAASC,gBAAAA,CAAgB,QAAa,IAAA,EAA2B;AACtE,EAAA,OAAO,kBAAA,CAAoB,GAAA,EAAK,GAAG,IAAI,CAAA;AACzC;AACO,SAASC,iBAAAA,CAAiB,UAAe,IAAA,EAA2B;AACzE,EAAA,OAAO,mBAAA,CAAqB,KAAA,EAAO,GAAG,IAAI,CAAA;AAC5C;AAKA,SAAS,SAAS,IAAA,EAAmB;AACnC,EAAA,MAAM,KAAA,GAAQ,OAAO,IAAA,EAAM,IAAA,IAAO,EAAG,UAAU,QAAA,GAAW,IAAA,CAAK,IAAA,EAAK,CAAE,KAAA,GAAQ,CAAA;AAC9E,EAAA,OAAO,KAAK,GAAA,CAAI,CAAA,EAAG,KAAK,IAAA,CAAK,KAAA,GAAQ,GAAI,CAAC,CAAA;AAC5C;AACO,SAASC,mBAAAA,CAAmB,UAAe,IAAA,EAA2B;AAC3E,EAAA,MAAM,KAAA,GAAQ,YAAA,EAAa,IAAK,SAAA,CAAU,KAAK,CAAA;AAC/C,EAAA,OAAO,sBAAuB,KAAA,EAAO,GAAG,IAAI,CAAA,CAAE,IAAA,CAAK,CAAC,IAAA,KAAc;AAChE,IAAA,KAAA,CAAM,KAAA,EAAO,QAAA,CAAS,IAAI,CAAC,CAAA;AAC3B,IAAA,OAAO,IAAA;AAAA,EACT,CAAC,CAAA;AACH;AACO,SAASC,uBAAAA,CAAuB,KAAA,EAAY,IAAA,EAAA,GAAc,IAAA,EAA2B;AAC1F,EAAA,MAAM,KAAA,GAAQ,YAAA,EAAa,IAAK,SAAA,CAAU,KAAK,CAAA;AAC/C,EAAA,OAAO,yBAAA,CAA2B,OAAO,IAAA,EAAM,GAAG,IAAI,CAAA,CAAE,IAAA,CAAK,CAAC,IAAA,KAAc;AAC1E,IAAA,KAAA,CAAM,KAAA,EAAO,QAAA,CAAS,IAAI,CAAC,CAAA;AAC3B,IAAA,OAAO,IAAA;AAAA,EACT,CAAC,CAAA;AACH;;;ACxIO,SAAS,eAAe,OAAA,EAA+B;AAC5D,EAAA,MAAMC,KAAAA,GAAO,IAAI,aAAA,CAAc,EAAE,MAAA,EAAQ,QAAQ,MAAA,EAAQ,QAAA,EAAU,OAAA,CAAQ,QAAA,EAAU,CAAA;AACrF,EAAA,iBAAA,CAAkB,EAAE,MAAAA,KAAAA,EAAM,eAAA,EAAiB,QAAQ,eAAA,EAAiB,OAAA,EAAS,OAAA,CAAQ,OAAA,EAAS,CAAA;AAChG","file":"web.mjs","sourcesContent":["/**\n * web/meter — the browser read meter. Same contract as the Node meter (count in\n * memory, flush ~1/min, never throw into the app), adapted to the browser:\n *\n * - no AsyncLocalStorage — labels come from web/context (synchronous),\n * - \"shutdown\" is the tab going hidden/closed, so we also flush on\n * visibilitychange→hidden and pagehide (a `fetch(..., {keepalive:true})`\n * survives the unload),\n * - it talks to a Sink exactly like the Node meter, so the wire shape is\n * identical and the same ingest receives both.\n */\nimport type { Sink, BucketsReport, OpCounts } from \"../sink\";\n\n// A resource unit. The browser Firestore adapter records \"read\"; kept generic so\n// each resource stays distinct and is never merged with another.\nexport type OpType = string;\n\n// ASCII Unit Separator — a bucket/collection name never contains it, so the\n// composite key splits back cleanly.\nconst SEP = \"\\u001f\"; // ASCII Unit Separator\n\n/** key = date <US> op <US> label → count */\nconst labelBuffer = new Map<string, number>();\n/** key = date <US> op <US> hour → count */\nconst hourBuffer = new Map<string, number>();\n/** key = date <US> op <US> 5-min-slot (\"HHMM\") → count — fast-verification grain. */\nconst minuteBuffer = new Map<string, number>();\n\nlet sink: Sink | null = null;\nlet flushIntervalMs = 60_000;\nlet onError: ((e: unknown) => void) | null = null;\nlet timer: ReturnType<typeof setInterval> | null = null;\nlet flushing = false;\nlet lifecycleBound = false;\nconst MAX_BUFFER_KEYS = 5_000;\n\nexport interface WebMeterConfig {\n sink: Sink;\n flushIntervalMs?: number;\n onError?: (e: unknown) => void;\n}\n\nexport function configureWebMeter(config: WebMeterConfig): void {\n sink = config.sink;\n if (config.flushIntervalMs && config.flushIntervalMs > 0) flushIntervalMs = config.flushIntervalMs;\n onError = config.onError ?? null;\n}\n\nconst utcDate = (): string => new Date().toISOString().slice(0, 10);\nconst utcHour = (): string => new Date().toISOString().slice(11, 13);\nconst utcFiveMin = (): string => {\n const iso = new Date().toISOString();\n return iso.slice(11, 13) + String(Math.floor(Number(iso.slice(14, 16)) / 5) * 5).padStart(2, \"0\");\n};\n\nfunction ensureLoop(): void {\n if (timer) return;\n timer = setInterval(() => void flushWeb(), flushIntervalMs);\n if (!lifecycleBound && typeof addEventListener === \"function\") {\n lifecycleBound = true;\n // The tab being hidden or torn down is the browser's \"shutdown\" — flush the\n // last window. keepalive on the sink's fetch lets it complete during unload.\n addEventListener(\"visibilitychange\", () => {\n if (typeof document !== \"undefined\" && document.visibilityState === \"hidden\") void flushWeb();\n });\n addEventListener(\"pagehide\", () => void flushWeb());\n }\n}\n\n/** Count `n` ops of `op` against `label`. Never throws. */\nexport function recordWeb(op: OpType, n: number, label: string): void {\n try {\n if (!Number.isFinite(n) || n <= 0) return;\n const date = utcDate();\n const lk = date + SEP + op + SEP + label;\n labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + n);\n const hk = date + SEP + op + SEP + utcHour();\n hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + n);\n const mk = date + SEP + op + SEP + utcFiveMin();\n minuteBuffer.set(mk, (minuteBuffer.get(mk) ?? 0) + n);\n ensureLoop();\n if (labelBuffer.size + hourBuffer.size + minuteBuffer.size > MAX_BUFFER_KEYS) void flushWeb();\n } catch {\n /* metering is best-effort — never disturb the page */\n }\n}\n\nfunction add(target: Record<string, OpCounts>, key: string, op: OpType, n: number): void {\n const bag = (target[key] ??= {});\n bag[op] = (bag[op] ?? 0) + n;\n}\n\n/** Coalesce the buffer into one report per UTC day and hand each to the Sink. */\nexport async function flushWeb(): Promise<void> {\n if (flushing) return;\n if (!sink) {\n labelBuffer.clear();\n hourBuffer.clear();\n minuteBuffer.clear();\n return;\n }\n if (labelBuffer.size === 0 && hourBuffer.size === 0 && minuteBuffer.size === 0) return;\n flushing = true;\n\n const labels = new Map(labelBuffer);\n const hours = new Map(hourBuffer);\n const minutes = new Map(minuteBuffer);\n labelBuffer.clear();\n hourBuffer.clear();\n minuteBuffer.clear();\n\n try {\n const byDate = new Map<string, BucketsReport>();\n const reportFor = (date: string): BucketsReport => {\n let r = byDate.get(date);\n if (!r) {\n r = { date, byLabel: {}, byHour: {}, byMinute: {} };\n byDate.set(date, r);\n }\n return r;\n };\n for (const [k, n] of labels) {\n const [date, op, label] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byLabel, label, op, n);\n }\n for (const [k, n] of hours) {\n const [date, op, hour] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byHour!, hour, op, n);\n }\n for (const [k, n] of minutes) {\n const [date, op, slot] = k.split(SEP) as [string, OpType, string];\n add(reportFor(date).byMinute!, slot, op, n);\n }\n for (const report of byDate.values()) {\n await sink.flush(report);\n }\n } catch (e) {\n onError?.(e);\n } finally {\n flushing = false;\n }\n}\n","/**\n * web/sink — reports the browser's coalesced rollup up to Crossdeck's ingest.\n *\n * Two differences from the Node sink, both forced by the browser:\n * - it authenticates with a PUBLISHABLE key (`cd_pub_live_`), never a secret — a\n * secret key cannot live in client code. (The ingest accepts publishable keys\n * for Buckets reports the same way the analytics SDK accepts them for events.)\n * - it uses `fetch(..., { keepalive: true })` so a report fired as the tab is\n * closing still completes.\n *\n * It performs ZERO database operations — it sends a summary, it does not read.\n */\nimport type { BucketsReport, Sink } from \"../sink\";\n\nconst DEFAULT_ENDPOINT = \"https://api.cross-deck.com/v1/buckets/report\";\n\nexport interface WebReportSinkConfig {\n /** The project's `cd_pub_live_` PUBLISHABLE key. */\n apiKey: string;\n endpoint?: string;\n}\n\nexport class WebReportSink implements Sink {\n private readonly endpoint: string;\n private readonly apiKey: string;\n\n constructor(config: WebReportSinkConfig) {\n this.endpoint = config.endpoint ?? DEFAULT_ENDPOINT;\n this.apiKey = config.apiKey;\n }\n\n async flush(report: BucketsReport): Promise<void> {\n const res = await fetch(this.endpoint, {\n method: \"POST\",\n keepalive: true,\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${this.apiKey}`,\n },\n body: JSON.stringify(report),\n });\n if (res.status !== 202) {\n throw new Error(`Buckets web report rejected: HTTP ${res.status}`);\n }\n }\n}\n","/**\n * web/context — the browser's tagging primitive.\n *\n * The Node collector rides AsyncLocalStorage to attribute reads to a bucket\n * across async fan-outs. The browser has no AsyncLocalStorage — but it doesn't\n * need one: a read (a `getDocs` call, an `onSnapshot` registration) is set up\n * SYNCHRONOUSLY, so a plain module-level \"current label\" captured at call time is\n * exact. `bucket(name, fn)` sets it for the synchronous body of `fn` and restores\n * it after — so the read inside picks up the name, and an `onSnapshot` listener\n * keeps that name for every future fire.\n */\n\nlet current: string | undefined;\n\n/** The bucket name in effect right now, or undefined (→ cascade to collection). */\nexport function currentLabel(): string | undefined {\n return current;\n}\n\n/**\n * Attribute every read SET UP inside `fn` to the bucket `name`:\n *\n * bucket(\"pulse-map\", () => onSnapshot(liveQuery, render));\n * // → that listener's reads all show as \"pulse-map\", forever\n */\nexport function bucket<T>(name: string, fn: () => T): T {\n const prev = current;\n current = name;\n try {\n return fn();\n } finally {\n current = prev;\n }\n}\n","/**\n * web/firestore — drop-in wrappers for the three Firestore client read calls.\n *\n * Swap your import source and nothing else:\n * - import { getDoc, getDocs, onSnapshot } from \"firebase/firestore\"\n * + import { getDoc, getDocs, onSnapshot } from \"@cross-deck/buckets/web\"\n *\n * Each wrapper calls the REAL Firestore function, counts the documents it\n * delivers (exactly what Firestore bills), labels it (your `bucket()` name, else\n * the collection), and returns the real result untouched. It can never change a\n * result or throw from the metering — same safety contract as the server trap.\n *\n * COUNTING:\n * - getDoc → 1 read\n * - getDocs → snapshot.size reads\n * - onSnapshot → on EVERY fire, the number of doc changes delivered\n * (first fire = all matching docs; each update = just the changed ones —\n * which is precisely what a listener is billed).\n */\nimport * as FS from \"firebase/firestore\";\nimport { recordWeb } from \"./meter\";\nimport { currentLabel } from \"./context\";\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\ntype AnyAsync = (...args: any[]) => Promise<any>;\n\n// Call the real reads through the namespace + loose aliases: a function missing on\n// an older `firebase` (e.g. getAggregateFromServer pre-v10) is simply `undefined`\n// rather than a hard import error, and the wrappers pass arguments through verbatim.\nconst rawGetDoc = FS.getDoc as AnyAsync;\nconst rawGetDocs = FS.getDocs as AnyAsync;\nconst rawOnSnapshot = FS.onSnapshot as (...args: any[]) => any;\nconst rawGetDocFromServer = (FS as any).getDocFromServer as AnyAsync | undefined;\nconst rawGetDocsFromServer = (FS as any).getDocsFromServer as AnyAsync | undefined;\nconst rawGetDocFromCache = (FS as any).getDocFromCache as AnyAsync | undefined;\nconst rawGetDocsFromCache = (FS as any).getDocsFromCache as AnyAsync | undefined;\nconst rawGetCountFromServer = (FS as any).getCountFromServer as AnyAsync | undefined;\nconst rawGetAggregateFromServer = (FS as any).getAggregateFromServer as AnyAsync | undefined;\n\n/** Best-effort collection label from a ref/query. PURE; never throws. */\nfunction collLabel(ref: any): string {\n try {\n const path: string =\n (typeof ref?.path === \"string\" && ref.path) ||\n (ref?._query?.path?.segments?.join?.(\"/\") ?? \"\") ||\n \"\";\n if (path) {\n const segs = path.split(\"/\").filter(Boolean);\n // even segment count → document path (…/coll/id); odd → collection.\n const coll = segs.length % 2 === 0 ? segs[segs.length - 2] : segs[segs.length - 1];\n return coll ? `col:${coll}` : \"uncategorized\";\n }\n const segs = ref?._query?.path?.segments;\n if (Array.isArray(segs) && segs.length) return `col:${segs[segs.length - 1]}`;\n } catch {\n /* never throw from labelling */\n }\n return \"uncategorized\";\n}\n\nfunction meter(label: string, n: number): void {\n try {\n recordWeb(\"read\", n, label);\n } catch {\n /* best-effort */\n }\n}\n\n/** Count the docs a snapshot delivers: a query's changed docs, or 1 for a doc. */\nfunction countSnap(snap: any): number {\n try {\n if (typeof snap?.docChanges === \"function\") return snap.docChanges().length;\n } catch {\n /* fall through */\n }\n return 1;\n}\n\nexport function getDoc(ref: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(ref);\n return rawGetDoc(ref, ...rest).then((snap: any) => {\n meter(label, 1);\n return snap;\n });\n}\n\nexport function getDocs(query: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(query);\n return rawGetDocs(query, ...rest).then((snap: any) => {\n meter(label, typeof snap?.size === \"number\" ? Math.max(snap.size, 1) : 1);\n return snap;\n });\n}\n\n/**\n * onSnapshot has several overloads — (ref, observer), (ref, onNext, onError,\n * onComplete), and either of those with a leading SnapshotListenOptions. We find\n * the next-handler wherever it is (a function or `observer.next`) and wrap it to\n * count on each fire, leaving every other argument exactly as passed.\n */\nexport function onSnapshot(ref: any, ...args: any[]): any {\n const label = currentLabel() ?? collLabel(ref);\n const wrapNext = (fn: any) =>\n typeof fn === \"function\"\n ? (snap: any) => {\n meter(label, countSnap(snap));\n return fn(snap);\n }\n : fn;\n\n const out = args.slice();\n // Leading options object (not a function, not an observer): skip it.\n let i = 0;\n if (out[0] && typeof out[0] !== \"function\" && !(\"next\" in out[0])) i = 1;\n\n if (out[i] && typeof out[i] === \"object\" && \"next\" in out[i]) {\n // observer form: clone with wrapped next\n out[i] = { ...out[i], next: wrapNext(out[i].next) };\n } else if (typeof out[i] === \"function\") {\n // callback form: wrap onNext (the first function)\n out[i] = wrapNext(out[i]);\n }\n\n return rawOnSnapshot(ref, ...out);\n}\n\n// ── Force-from-server reads — bill exactly like getDoc / getDocs ──────────────\nexport function getDocFromServer(ref: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(ref);\n return rawGetDocFromServer!(ref, ...rest).then((snap: any) => {\n meter(label, 1);\n return snap;\n });\n}\nexport function getDocsFromServer(query: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(query);\n return rawGetDocsFromServer!(query, ...rest).then((snap: any) => {\n meter(label, typeof snap?.size === \"number\" ? Math.max(snap.size, 1) : 1);\n return snap;\n });\n}\n\n// ── Cache-only reads — NOT billed, so pass through and count NOTHING ──────────\n// (Counting these would over-report; a cache hit costs you nothing.)\nexport function getDocFromCache(ref: any, ...rest: any[]): Promise<any> {\n return rawGetDocFromCache!(ref, ...rest);\n}\nexport function getDocsFromCache(query: any, ...rest: any[]): Promise<any> {\n return rawGetDocsFromCache!(query, ...rest);\n}\n\n// ── Aggregation — count() / sum() / average() ────────────────────────────────\n// Honest estimate: ceil(count / 1000), min 1 — Firestore never exposes the exact\n// index-entry count. Same model as the server trap, so the numbers reconcile.\nfunction aggReads(snap: any): number {\n const count = typeof snap?.data?.()?.count === \"number\" ? snap.data().count : 0;\n return Math.max(1, Math.ceil(count / 1000));\n}\nexport function getCountFromServer(query: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(query);\n return rawGetCountFromServer!(query, ...rest).then((snap: any) => {\n meter(label, aggReads(snap));\n return snap;\n });\n}\nexport function getAggregateFromServer(query: any, spec: any, ...rest: any[]): Promise<any> {\n const label = currentLabel() ?? collLabel(query);\n return rawGetAggregateFromServer!(query, spec, ...rest).then((snap: any) => {\n meter(label, aggReads(snap));\n return snap;\n });\n}\n","/**\n * @cross-deck/buckets/web — the BROWSER collector.\n *\n * Most Firebase apps read straight from the browser (live `onSnapshot`\n * listeners, `getDocs`, `getDoc`) — reads billed to your project that a\n * server-side collector can never see. This adapter closes that hole.\n *\n * Setup (two lines + one import swap):\n *\n * import { initBucketsWeb, bucket } from \"@cross-deck/buckets/web\";\n * import { getDoc, getDocs, onSnapshot } from \"@cross-deck/buckets/web\"; // was \"firebase/firestore\"\n *\n * initBucketsWeb({ apiKey: \"cd_pub_live_…\" }); // your PUBLISHABLE key\n *\n * bucket(\"pulse-map\", () => onSnapshot(liveQuery, render));\n *\n * Every read those wrappers see is counted, labelled, and reported up the same\n * ingest pipe as the server collector — so the dashboard shows server AND browser\n * reads side by side.\n */\nimport { configureWebMeter, flushWeb, type WebMeterConfig } from \"./meter\";\nimport { WebReportSink } from \"./sink\";\n\nexport interface InitWebOptions {\n /** The project's `cd_pub_live_` PUBLISHABLE key (safe in client code). */\n apiKey: string;\n /** Override the report endpoint (defaults to Crossdeck's ingest). */\n endpoint?: string;\n /** How often to flush coalesced counts (ms). Default 60_000. */\n flushIntervalMs?: number;\n /** Notified when a flush fails, so a dropped window is never silent. */\n onError?: WebMeterConfig[\"onError\"];\n}\n\n/** Configure the browser collector once, at app start. */\nexport function initBucketsWeb(options: InitWebOptions): void {\n const sink = new WebReportSink({ apiKey: options.apiKey, endpoint: options.endpoint });\n configureWebMeter({ sink, flushIntervalMs: options.flushIntervalMs, onError: options.onError });\n}\n\n// The tagging verb + the metered read wrappers (swap your firebase/firestore\n// import for these). Cache-only reads pass through uncounted (they aren't billed).\nexport { bucket } from \"./context\";\nexport {\n getDoc,\n getDocs,\n onSnapshot,\n getDocFromServer,\n getDocsFromServer,\n getDocFromCache,\n getDocsFromCache,\n getCountFromServer,\n getAggregateFromServer,\n} from \"./firestore\";\nexport { flushWeb as flush } from \"./meter\";\n\n// The sink seam — for self-hosting the browser rollups instead of reporting to Crossdeck.\nexport { WebReportSink, type WebReportSinkConfig } from \"./sink\";\nexport type { BucketsReport, OpCounts, Sink } from \"../sink\";\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cross-deck/buckets",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Know exactly what every database read costs you — and who caused it. A tiny, never-throws read-cost collector for Firestore, server AND browser.",
5
5
  "license": "MIT",
6
6
  "author": "Crossdeck",