@cross-deck/buckets 0.3.0 → 0.4.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/web.d.mts CHANGED
@@ -38,6 +38,12 @@ declare function getDocs(query: any, ...rest: any[]): Promise<any>;
38
38
  * count on each fire, leaving every other argument exactly as passed.
39
39
  */
40
40
  declare function onSnapshot(ref: any, ...args: any[]): any;
41
+ declare function getDocFromServer(ref: any, ...rest: any[]): Promise<any>;
42
+ declare function getDocsFromServer(query: any, ...rest: any[]): Promise<any>;
43
+ declare function getDocFromCache(ref: any, ...rest: any[]): Promise<any>;
44
+ declare function getDocsFromCache(query: any, ...rest: any[]): Promise<any>;
45
+ declare function getCountFromServer(query: any, ...rest: any[]): Promise<any>;
46
+ declare function getAggregateFromServer(query: any, spec: any, ...rest: any[]): Promise<any>;
41
47
 
42
48
  /**
43
49
  * web/sink — reports the browser's coalesced rollup up to Crossdeck's ingest.
@@ -98,4 +104,4 @@ interface InitWebOptions {
98
104
  /** Configure the browser collector once, at app start. */
99
105
  declare function initBucketsWeb(options: InitWebOptions): void;
100
106
 
101
- export { BucketsReport, type InitWebOptions, Sink, WebReportSink, type WebReportSinkConfig, bucket, flushWeb as flush, getDoc, getDocs, initBucketsWeb, onSnapshot };
107
+ export { BucketsReport, type InitWebOptions, Sink, WebReportSink, type WebReportSinkConfig, bucket, flushWeb as flush, getAggregateFromServer, getCountFromServer, getDoc, getDocFromCache, getDocFromServer, getDocs, getDocsFromCache, getDocsFromServer, initBucketsWeb, onSnapshot };
package/dist/web.d.ts CHANGED
@@ -38,6 +38,12 @@ declare function getDocs(query: any, ...rest: any[]): Promise<any>;
38
38
  * count on each fire, leaving every other argument exactly as passed.
39
39
  */
40
40
  declare function onSnapshot(ref: any, ...args: any[]): any;
41
+ declare function getDocFromServer(ref: any, ...rest: any[]): Promise<any>;
42
+ declare function getDocsFromServer(query: any, ...rest: any[]): Promise<any>;
43
+ declare function getDocFromCache(ref: any, ...rest: any[]): Promise<any>;
44
+ declare function getDocsFromCache(query: any, ...rest: any[]): Promise<any>;
45
+ declare function getCountFromServer(query: any, ...rest: any[]): Promise<any>;
46
+ declare function getAggregateFromServer(query: any, spec: any, ...rest: any[]): Promise<any>;
41
47
 
42
48
  /**
43
49
  * web/sink — reports the browser's coalesced rollup up to Crossdeck's ingest.
@@ -98,4 +104,4 @@ interface InitWebOptions {
98
104
  /** Configure the browser collector once, at app start. */
99
105
  declare function initBucketsWeb(options: InitWebOptions): void;
100
106
 
101
- export { BucketsReport, type InitWebOptions, Sink, WebReportSink, type WebReportSinkConfig, bucket, flushWeb as flush, getDoc, getDocs, initBucketsWeb, onSnapshot };
107
+ export { BucketsReport, type InitWebOptions, Sink, WebReportSink, type WebReportSinkConfig, bucket, flushWeb as flush, getAggregateFromServer, getCountFromServer, getDoc, getDocFromCache, getDocFromServer, getDocs, getDocsFromCache, getDocsFromServer, initBucketsWeb, onSnapshot };
package/dist/web.js CHANGED
@@ -1,6 +1,26 @@
1
1
  'use strict';
2
2
 
3
- var firestore = require('firebase/firestore');
3
+ var FS = require('firebase/firestore');
4
+
5
+ function _interopNamespace(e) {
6
+ if (e && e.__esModule) return e;
7
+ var n = Object.create(null);
8
+ if (e) {
9
+ Object.keys(e).forEach(function (k) {
10
+ if (k !== 'default') {
11
+ var d = Object.getOwnPropertyDescriptor(e, k);
12
+ Object.defineProperty(n, k, d.get ? d : {
13
+ enumerable: true,
14
+ get: function () { return e[k]; }
15
+ });
16
+ }
17
+ });
18
+ }
19
+ n.default = e;
20
+ return Object.freeze(n);
21
+ }
22
+
23
+ var FS__namespace = /*#__PURE__*/_interopNamespace(FS);
4
24
 
5
25
  // src/web/meter.ts
6
26
  var SEP = "";
@@ -128,9 +148,15 @@ function bucket(name, fn) {
128
148
  current = prev;
129
149
  }
130
150
  }
131
- var rawGetDoc = firestore.getDoc;
132
- var rawGetDocs = firestore.getDocs;
133
- var rawOnSnapshot = firestore.onSnapshot;
151
+ var rawGetDoc = FS__namespace.getDoc;
152
+ var rawGetDocs = FS__namespace.getDocs;
153
+ var rawOnSnapshot = FS__namespace.onSnapshot;
154
+ var rawGetDocFromServer = FS__namespace.getDocFromServer;
155
+ var rawGetDocsFromServer = FS__namespace.getDocsFromServer;
156
+ var rawGetDocFromCache = FS__namespace.getDocFromCache;
157
+ var rawGetDocsFromCache = FS__namespace.getDocsFromCache;
158
+ var rawGetCountFromServer = FS__namespace.getCountFromServer;
159
+ var rawGetAggregateFromServer = FS__namespace.getAggregateFromServer;
134
160
  function collLabel(ref) {
135
161
  try {
136
162
  const path = typeof ref?.path === "string" && ref.path || (ref?._query?.path?.segments?.join?.("/") ?? "") || "";
@@ -158,21 +184,21 @@ function countSnap(snap) {
158
184
  }
159
185
  return 1;
160
186
  }
161
- function getDoc(ref, ...rest) {
187
+ function getDoc2(ref, ...rest) {
162
188
  const label = currentLabel() ?? collLabel(ref);
163
189
  return rawGetDoc(ref, ...rest).then((snap) => {
164
190
  meter(label, 1);
165
191
  return snap;
166
192
  });
167
193
  }
168
- function getDocs(query, ...rest) {
194
+ function getDocs2(query, ...rest) {
169
195
  const label = currentLabel() ?? collLabel(query);
170
196
  return rawGetDocs(query, ...rest).then((snap) => {
171
197
  meter(label, typeof snap?.size === "number" ? Math.max(snap.size, 1) : 1);
172
198
  return snap;
173
199
  });
174
200
  }
175
- function onSnapshot(ref, ...args) {
201
+ function onSnapshot2(ref, ...args) {
176
202
  const label = currentLabel() ?? collLabel(ref);
177
203
  const wrapNext = (fn) => typeof fn === "function" ? (snap) => {
178
204
  meter(label, countSnap(snap));
@@ -188,6 +214,44 @@ function onSnapshot(ref, ...args) {
188
214
  }
189
215
  return rawOnSnapshot(ref, ...out);
190
216
  }
217
+ function getDocFromServer2(ref, ...rest) {
218
+ const label = currentLabel() ?? collLabel(ref);
219
+ return rawGetDocFromServer(ref, ...rest).then((snap) => {
220
+ meter(label, 1);
221
+ return snap;
222
+ });
223
+ }
224
+ function getDocsFromServer2(query, ...rest) {
225
+ const label = currentLabel() ?? collLabel(query);
226
+ return rawGetDocsFromServer(query, ...rest).then((snap) => {
227
+ meter(label, typeof snap?.size === "number" ? Math.max(snap.size, 1) : 1);
228
+ return snap;
229
+ });
230
+ }
231
+ function getDocFromCache2(ref, ...rest) {
232
+ return rawGetDocFromCache(ref, ...rest);
233
+ }
234
+ function getDocsFromCache2(query, ...rest) {
235
+ return rawGetDocsFromCache(query, ...rest);
236
+ }
237
+ function aggReads(snap) {
238
+ const count = typeof snap?.data?.()?.count === "number" ? snap.data().count : 0;
239
+ return Math.max(1, Math.ceil(count / 1e3));
240
+ }
241
+ function getCountFromServer2(query, ...rest) {
242
+ const label = currentLabel() ?? collLabel(query);
243
+ return rawGetCountFromServer(query, ...rest).then((snap) => {
244
+ meter(label, aggReads(snap));
245
+ return snap;
246
+ });
247
+ }
248
+ function getAggregateFromServer2(query, spec, ...rest) {
249
+ const label = currentLabel() ?? collLabel(query);
250
+ return rawGetAggregateFromServer(query, spec, ...rest).then((snap) => {
251
+ meter(label, aggReads(snap));
252
+ return snap;
253
+ });
254
+ }
191
255
 
192
256
  // src/web/index.ts
193
257
  function initBucketsWeb(options) {
@@ -198,9 +262,15 @@ function initBucketsWeb(options) {
198
262
  exports.WebReportSink = WebReportSink;
199
263
  exports.bucket = bucket;
200
264
  exports.flush = flushWeb;
201
- exports.getDoc = getDoc;
202
- exports.getDocs = getDocs;
265
+ exports.getAggregateFromServer = getAggregateFromServer2;
266
+ exports.getCountFromServer = getCountFromServer2;
267
+ exports.getDoc = getDoc2;
268
+ exports.getDocFromCache = getDocFromCache2;
269
+ exports.getDocFromServer = getDocFromServer2;
270
+ exports.getDocs = getDocs2;
271
+ exports.getDocsFromCache = getDocsFromCache2;
272
+ exports.getDocsFromServer = getDocsFromServer2;
203
273
  exports.initBucketsWeb = initBucketsWeb;
204
- exports.onSnapshot = onSnapshot;
274
+ exports.onSnapshot = onSnapshot2;
205
275
  //# sourceMappingURL=web.js.map
206
276
  //# sourceMappingURL=web.js.map
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":["_getDoc","_getDocs","_onSnapshot","segs","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\nexport type OpType = \"read\" | \"write\" | \"delete\";\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 {\n getDoc as _getDoc,\n getDocs as _getDocs,\n onSnapshot as _onSnapshot,\n} from \"firebase/firestore\";\nimport { recordWeb } from \"./meter\";\nimport { currentLabel } from \"./context\";\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\n// The real Firestore reads have many typed overloads; we pass arguments through\n// verbatim, so call them through loose aliases (the wrappers preserve behaviour).\nconst rawGetDoc = _getDoc as (...args: any[]) => Promise<any>;\nconst rawGetDocs = _getDocs as (...args: any[]) => Promise<any>;\nconst rawOnSnapshot = _onSnapshot as (...args: any[]) => any;\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 * @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.\nexport { bucket } from \"./context\";\nexport { getDoc, getDocs, onSnapshot } 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\nexport type OpType = \"read\" | \"write\" | \"delete\";\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"]}
package/dist/web.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { getDoc as getDoc$1, getDocs as getDocs$1, onSnapshot as onSnapshot$1 } from 'firebase/firestore';
1
+ import * as FS from 'firebase/firestore';
2
2
 
3
3
  // src/web/meter.ts
4
4
  var SEP = "";
@@ -126,9 +126,15 @@ function bucket(name, fn) {
126
126
  current = prev;
127
127
  }
128
128
  }
129
- var rawGetDoc = getDoc$1;
130
- var rawGetDocs = getDocs$1;
131
- var rawOnSnapshot = onSnapshot$1;
129
+ var rawGetDoc = FS.getDoc;
130
+ var rawGetDocs = FS.getDocs;
131
+ var rawOnSnapshot = FS.onSnapshot;
132
+ var rawGetDocFromServer = FS.getDocFromServer;
133
+ var rawGetDocsFromServer = FS.getDocsFromServer;
134
+ var rawGetDocFromCache = FS.getDocFromCache;
135
+ var rawGetDocsFromCache = FS.getDocsFromCache;
136
+ var rawGetCountFromServer = FS.getCountFromServer;
137
+ var rawGetAggregateFromServer = FS.getAggregateFromServer;
132
138
  function collLabel(ref) {
133
139
  try {
134
140
  const path = typeof ref?.path === "string" && ref.path || (ref?._query?.path?.segments?.join?.("/") ?? "") || "";
@@ -156,21 +162,21 @@ function countSnap(snap) {
156
162
  }
157
163
  return 1;
158
164
  }
159
- function getDoc(ref, ...rest) {
165
+ function getDoc2(ref, ...rest) {
160
166
  const label = currentLabel() ?? collLabel(ref);
161
167
  return rawGetDoc(ref, ...rest).then((snap) => {
162
168
  meter(label, 1);
163
169
  return snap;
164
170
  });
165
171
  }
166
- function getDocs(query, ...rest) {
172
+ function getDocs2(query, ...rest) {
167
173
  const label = currentLabel() ?? collLabel(query);
168
174
  return rawGetDocs(query, ...rest).then((snap) => {
169
175
  meter(label, typeof snap?.size === "number" ? Math.max(snap.size, 1) : 1);
170
176
  return snap;
171
177
  });
172
178
  }
173
- function onSnapshot(ref, ...args) {
179
+ function onSnapshot2(ref, ...args) {
174
180
  const label = currentLabel() ?? collLabel(ref);
175
181
  const wrapNext = (fn) => typeof fn === "function" ? (snap) => {
176
182
  meter(label, countSnap(snap));
@@ -186,6 +192,44 @@ function onSnapshot(ref, ...args) {
186
192
  }
187
193
  return rawOnSnapshot(ref, ...out);
188
194
  }
195
+ function getDocFromServer2(ref, ...rest) {
196
+ const label = currentLabel() ?? collLabel(ref);
197
+ return rawGetDocFromServer(ref, ...rest).then((snap) => {
198
+ meter(label, 1);
199
+ return snap;
200
+ });
201
+ }
202
+ function getDocsFromServer2(query, ...rest) {
203
+ const label = currentLabel() ?? collLabel(query);
204
+ return rawGetDocsFromServer(query, ...rest).then((snap) => {
205
+ meter(label, typeof snap?.size === "number" ? Math.max(snap.size, 1) : 1);
206
+ return snap;
207
+ });
208
+ }
209
+ function getDocFromCache2(ref, ...rest) {
210
+ return rawGetDocFromCache(ref, ...rest);
211
+ }
212
+ function getDocsFromCache2(query, ...rest) {
213
+ return rawGetDocsFromCache(query, ...rest);
214
+ }
215
+ function aggReads(snap) {
216
+ const count = typeof snap?.data?.()?.count === "number" ? snap.data().count : 0;
217
+ return Math.max(1, Math.ceil(count / 1e3));
218
+ }
219
+ function getCountFromServer2(query, ...rest) {
220
+ const label = currentLabel() ?? collLabel(query);
221
+ return rawGetCountFromServer(query, ...rest).then((snap) => {
222
+ meter(label, aggReads(snap));
223
+ return snap;
224
+ });
225
+ }
226
+ function getAggregateFromServer2(query, spec, ...rest) {
227
+ const label = currentLabel() ?? collLabel(query);
228
+ return rawGetAggregateFromServer(query, spec, ...rest).then((snap) => {
229
+ meter(label, aggReads(snap));
230
+ return snap;
231
+ });
232
+ }
189
233
 
190
234
  // src/web/index.ts
191
235
  function initBucketsWeb(options) {
@@ -193,6 +237,6 @@ function initBucketsWeb(options) {
193
237
  configureWebMeter({ sink: sink2, flushIntervalMs: options.flushIntervalMs, onError: options.onError });
194
238
  }
195
239
 
196
- export { WebReportSink, bucket, flushWeb as flush, getDoc, getDocs, initBucketsWeb, onSnapshot };
240
+ export { WebReportSink, bucket, flushWeb as flush, getAggregateFromServer2 as getAggregateFromServer, getCountFromServer2 as getCountFromServer, getDoc2 as getDoc, getDocFromCache2 as getDocFromCache, getDocFromServer2 as getDocFromServer, getDocs2 as getDocs, getDocsFromCache2 as getDocsFromCache, getDocsFromServer2 as getDocsFromServer, initBucketsWeb, onSnapshot2 as onSnapshot };
197
241
  //# sourceMappingURL=web.mjs.map
198
242
  //# sourceMappingURL=web.mjs.map
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":["_getDoc","_getDocs","_onSnapshot","segs","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\nexport type OpType = \"read\" | \"write\" | \"delete\";\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 {\n getDoc as _getDoc,\n getDocs as _getDocs,\n onSnapshot as _onSnapshot,\n} from \"firebase/firestore\";\nimport { recordWeb } from \"./meter\";\nimport { currentLabel } from \"./context\";\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\n// The real Firestore reads have many typed overloads; we pass arguments through\n// verbatim, so call them through loose aliases (the wrappers preserve behaviour).\nconst rawGetDoc = _getDoc as (...args: any[]) => Promise<any>;\nconst rawGetDocs = _getDocs as (...args: any[]) => Promise<any>;\nconst rawOnSnapshot = _onSnapshot as (...args: any[]) => any;\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 * @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.\nexport { bucket } from \"./context\";\nexport { getDoc, getDocs, onSnapshot } 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":"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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\nexport type OpType = \"read\" | \"write\" | \"delete\";\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"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cross-deck/buckets",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -17,7 +17,8 @@
17
17
  "types": "./dist/web.d.ts",
18
18
  "import": "./dist/web.mjs",
19
19
  "require": "./dist/web.js"
20
- }
20
+ },
21
+ "./package.json": "./package.json"
21
22
  },
22
23
  "files": [
23
24
  "dist",