@cross-deck/buckets 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Crossdeck
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,449 @@
1
+ <div align="center">
2
+
3
+ # Buckets
4
+
5
+ ### Know exactly what every database read costs you — and who caused it.
6
+
7
+ **Buckets is a zero-overhead cost attribution layer for your backend.**
8
+ Every read, write, and delete is tagged to the feature that served it and the
9
+ user who triggered it — automatically, with no blind spots, and without ever
10
+ becoming a cost itself.
11
+
12
+ [![License: MIT](https://img.shields.io/badge/License-MIT-black.svg)](LICENSE)
13
+ [![npm](https://img.shields.io/badge/npm-%40cross-deck%2Fbuckets-black)](https://www.npmjs.com/package/@cross-deck/buckets)
14
+ [![Firestore](https://img.shields.io/badge/datastore-Firestore-black)](#datastore-support)
15
+ [![Made by Crossdeck](https://img.shields.io/badge/made%20by-Crossdeck-black)](https://cross-deck.com)
16
+
17
+ </div>
18
+
19
+ ---
20
+
21
+ ## The 4am problem
22
+
23
+ You ship a feature. A week later your database bill is up 5×. Your provider's
24
+ console shows you *one number*: **9.6M reads today.** It does not tell you which
25
+ feature, which query, or which users are responsible. So you start guessing —
26
+ adding logs, bisecting deploys, staring at dashboards at 4am.
27
+
28
+ We lived this. On our own product, one tile was quietly issuing 15,000 reads
29
+ *per render*. It was hiding in plain sight for a day. When we finally instrumented
30
+ our read sites by hand, we *still* missed the path that mattered most — the
31
+ ingest pipeline, **1.4M reads a day, the majority of our entire bill, invisible.**
32
+
33
+ The lesson: **humans instrument what they're looking at, and miss the path that
34
+ matters.** Manual cost tracking doesn't fail loudly. It fails silently, and you
35
+ find out on the invoice.
36
+
37
+ Buckets fixes this by construction.
38
+
39
+ ---
40
+
41
+ ## Quickstart
42
+
43
+ ```bash
44
+ npm install @cross-deck/buckets
45
+ ```
46
+
47
+ **1. Install the meter once, at process start.** From this line on, *every*
48
+ operation on *every* code path is counted — no per-call-site work — and a coalesced
49
+ summary is reported to your Crossdeck project about once a minute. Your `cd_sk_`
50
+ secret key is all the wiring there is.
51
+
52
+ ```ts
53
+ import { init, installFirestoreMeter } from "@cross-deck/buckets";
54
+ import { getFirestore, Firestore, Query, DocumentReference } from "firebase-admin/firestore";
55
+
56
+ init({ apiKey: process.env.CROSSDECK_SECRET_KEY }); // reports up to Crossdeck (~1/min)
57
+ installFirestoreMeter({ Firestore, Query, DocumentReference }); // the trap
58
+ ```
59
+
60
+ That's the whole setup. The collector now sits in your path to your database,
61
+ counts in memory, and reports a tiny summary up — **it never reads your data and
62
+ never adds a query.**
63
+
64
+ **2. Name the paths that matter** with `bucket()` — every operation inside is
65
+ attributed to that name (anything you don't name still shows up, labelled by its
66
+ collection).
67
+
68
+ ```ts
69
+ import { bucket } from "@cross-deck/buckets";
70
+
71
+ await bucket("pulse-map", async () => {
72
+ const dots = await db.collection("visitors").where("live", "==", true).get(); // → pulse-map
73
+ const owner = await db.doc(`projects/${appId}`).get(); // → pulse-map
74
+ });
75
+ ```
76
+
77
+ **3. That's it.** Open your Crossdeck dashboard and your operations are there —
78
+ `pulse-map`, every other bucket you named, and an `unknown` row for anything you
79
+ haven't named yet. Buckets counted them at the database driver, not in your code.
80
+
81
+ ---
82
+
83
+ ## What you get
84
+
85
+ A small, cheap, daily document per app — the **rollup**. This is the entire output,
86
+ and it's a stable, public schema you can read with or without this library:
87
+
88
+ ```jsonc
89
+ // costRollups/production_2026-06-18_proj_3a8f137
90
+ {
91
+ "env": "production",
92
+ "date": "2026-06-18",
93
+ "appId": "proj_3a8f137",
94
+
95
+ // who caused it → which feature → op type
96
+ "ops": {
97
+ "runtime": { "pulse-map": { "read": 412000, "write": 8 },
98
+ "dashboard": { "read": 765000 } },
99
+ "internal": { "reconcile": { "read": 1200, "write": 96 } }
100
+ },
101
+
102
+ // the fine grain: which surface / layer spent the reads
103
+ "byLabel": {
104
+ "people-feed": { "read": 412000 },
105
+ "people-journey": { "read": 3000 },
106
+ "col:events": { "read": 21000 }
107
+ }
108
+ }
109
+ ```
110
+
111
+ Now "9.6M reads today" becomes *"765k of them are the dashboard, on the runtime
112
+ path — and within that, the people-feed layer is 137× heavier than the
113
+ journey-detail layer."* That's the difference between a number and an answer.
114
+
115
+ ---
116
+
117
+ ## "Did my fix work?" — the one-click loop
118
+
119
+ Buckets keeps reads at **hourly** grain for one reason: so you can ship a change
120
+ and *watch it land the same hour*, not guess from tomorrow's bill.
121
+
122
+ The loop:
123
+
124
+ 1. You ship a fix to a heavy read path.
125
+ 2. You click **I shipped a fix** on the dashboard. That stamps the exact moment.
126
+ 3. Buckets splits the timeline there and shows the verdict in **reads / hour** —
127
+ the hours *before* your fix vs the hours *after* it:
128
+
129
+ ```
130
+ since your fix · 2h ago
131
+ 1,240 → 310 reads / hour −75% 930 fewer reads / hour · 2h observed
132
+ ```
133
+
134
+ 4. The first full hour after you click gives a real number; it settles as more
135
+ hours land. No marker set yet? The header shows the plain day-over-day rate and
136
+ a button to start the loop.
137
+
138
+ It is just a marker — a timestamp the dashboard remembers per project. Click it
139
+ again when you ship again (it moves to *now*); **clear** it to go back to the
140
+ day-over-day view. Nothing about the marker touches your read path or your bill;
141
+ it only changes where the dashboard draws the *before/after* line.
142
+
143
+ > Why a button and not a date field: a fix isn't a *day*, it's a *moment*. A date
144
+ > picker can't tell you whether the deploy you pushed twenty minutes ago worked —
145
+ > hourly before/after can.
146
+
147
+ ---
148
+
149
+ ## How it works
150
+
151
+ Three ideas, stacked. Understand these and you understand the whole library.
152
+
153
+ ```
154
+ request boundary every read, anywhere
155
+ │ │
156
+ ▼ ▼
157
+ ┌──────────────┐ AsyncLocalStorage ┌──────────────────┐
158
+ │ ① THE TAG │ ─────────────────────▶ │ ② THE TRAP │
159
+ │ tag once, │ ambient context │ patch the SDK │
160
+ │ at the edge │ │ once, not your │
161
+ └──────────────┘ │ call sites │
162
+ └────────┬─────────┘
163
+
164
+ ┌──────────────────┐
165
+ │ ③ THE METER │
166
+ │ count in memory,│
167
+ │ flush ~1×/min │
168
+ └────────┬─────────┘
169
+
170
+ the rollup doc
171
+ ```
172
+
173
+ **① Tag once at the edge, attribute at the leaf.** Set the tag when a request
174
+ arrives; it propagates through every async fan-out automatically. Attribution
175
+ happens where the read *executes*, not where you guessed at the boundary.
176
+
177
+ **② Trap at the SDK, not at the call site.** Buckets patches the database driver's
178
+ read methods once. From then on every read is counted under the ambient tag — the
179
+ ingest job, the cron, the trigger, the path you forgot. **No blind spots, by
180
+ construction.** This is the part hand-rolled instrumentation can never get right.
181
+
182
+ **③ Count in memory, write rarely.** Counts accumulate in-process and flush to one
183
+ incremented document per (app, day) about once a minute — regardless of whether
184
+ you served 10 operations or 10 million. **The monitor never becomes the thing it
185
+ monitors.**
186
+
187
+ ---
188
+
189
+ ## Safe to put on your read path
190
+
191
+ Buckets patches your database driver. That demands a higher bar than most
192
+ libraries, so every wrapper is defensive by construction:
193
+
194
+ 1. it calls the **real** method first and captures the result,
195
+ 2. it counts inside a `try/catch` that **cannot throw into your code**,
196
+ 3. it **always returns the real result, untouched.**
197
+
198
+ It physically cannot break a read, change a result, or add latency beyond a single
199
+ in-memory counter increment. If a count is ever wrong, it's a *measurement* error —
200
+ never a correctness or availability one. Install is idempotent. A failed flush
201
+ drops one window of counts rather than risk corrupting anything.
202
+
203
+ > Buckets is observability, not a transactional ledger. It is built to be wrong
204
+ > by a rounding error under catastrophe, and never, ever to take your app down.
205
+
206
+ | Guarantee | How |
207
+ |---|---|
208
+ | **Every read is caught** | SDK-level trap — no read on any path is ever uncounted or silent |
209
+ | **Every read is labeled** | Path-based cascade always tags the collection + project, even untagged |
210
+ | **Untagged is loud, never hidden** | Reads outside a tagged context surface as `unknown` coverage — surfaced, never dropped |
211
+ | **Never a cost driver** | In-memory buffers, ~1 write/min per app, tiny daily docs |
212
+ | **Never breaks a read** | Real-method-first · count-in-try/catch · always-return-untouched |
213
+ | **Concurrency-safe** | Atomic increments; snapshot-and-clear before each flush |
214
+ | **Honest under failure** | A dropped flush loses a window, never double-counts |
215
+
216
+ ### What "no blind spots" actually means
217
+
218
+ Be precise, because the difference matters: **the trap guarantees every read is
219
+ *caught* and labeled by its collection — none is ever silent.** Sorting a read into
220
+ a *feature* and an *origin* ("this was the pulse-map, on a user's behalf") requires a
221
+ tag set at the request boundary. A read that runs outside any tagged context is
222
+ still caught and still labeled by collection (`col:events`) and project — it simply
223
+ lands in the **`unknown`** origin until you tag that entry point.
224
+
225
+ `unknown` is **first-class and loud**, never folded away and never filtered out of
226
+ the surface. A growing `unknown` bucket is the meter telling you "there's a real
227
+ read path here you haven't tagged yet" — which is exactly the signal you want, and
228
+ the opposite of a blind spot. Tag the entry point and it resolves into a named
229
+ feature. The one thing that never happens is a read going *uncounted*.
230
+
231
+ ---
232
+
233
+ ## From `unknown` to named — tagging
234
+
235
+ The trap catches every read for free and labels it by collection (`col:events`) —
236
+ that answers *what's being read.* Buckets becomes genuinely useful the moment you
237
+ **name the read path yourself** — wrap it in a bucket:
238
+
239
+ ```ts
240
+ import { bucket } from "@cross-deck/buckets";
241
+
242
+ // every read inside here is attributed to "nightly-export"
243
+ await bucket("nightly-export", async () => {
244
+ const rows = await db.collection("events").where("exported", "==", null).get();
245
+ // …
246
+ });
247
+ ```
248
+
249
+ Now those `col:events` reads that were sitting under `unknown` show up as
250
+ `nightly-export` on the dashboard. See an `unknown` bucket you can't explain?
251
+ **Drill in, wrap that path in a `bucket()`, ship, look again** — and keep going,
252
+ coarse to fine, until the read is named all the way down to the line you care
253
+ about. Two grains:
254
+
255
+ - **Tag a bucket** (coarse) — a whole handler or job: `bucket("pulse-map", handler)`
256
+ - **Tag a single read** (fine) — one query: `bucket("owner-lookup", () => db.doc(id).get())`
257
+
258
+ `unknown` is never a dead end. It's a to-do with a one-line fix — exactly like a
259
+ custom analytics event you haven't named yet. You tag until you've found your
260
+ source; Buckets just shows the names resolving in.
261
+
262
+ ---
263
+
264
+ ## What Buckets is — and what it deliberately isn't
265
+
266
+ Buckets is **telemetry, done right.** It tells you *what* your costs are and
267
+ *exactly where they come from.* It does **not** tell you *why* a number changed or
268
+ *what to do about it* — and that restraint is intentional.
269
+
270
+ The labels Buckets emits are deliberately low-level: the collection, the feature,
271
+ the origin, the count. **It will never write an interpretation** — no
272
+ `scan-on-load`, no `regression`, no `anomaly`. Those are judgements, and judgement
273
+ is a separate concern that lives in a separate layer.
274
+
275
+ We think that's the honest line. Collection should be a free, open, commodity
276
+ primitive that the whole ecosystem can build on — so we open-sourced it, and we
277
+ publish the rollup schema so you can consume it with any tool you like, including
278
+ your own. **The best place to read Buckets data should be earned, never locked.**
279
+
280
+ ### Cost should page you like an exception — not surprise you like an invoice
281
+
282
+ You already know what your code *should* do. You wrote the analytics pipeline; you
283
+ know it should read ~20k times a day, not 2M. The problem has never been a lack of
284
+ knowledge — it's that when reality departs from what you know, **nothing tells
285
+ you.** You find out at month-end, on a bill that's already due.
286
+
287
+ That's the difference between the two developers who hit the same bug. One reviews
288
+ the console at the end of the month and finds read volume that's been running ~100×
289
+ normal for weeks — a *verdict*, already billed, no cause attached. The other gets a
290
+ message ninety seconds in — *"analytics is at 2M reads, expected 20k"* — opens the
291
+ dashboard, sees which bucket, knows the code, and ships the fix before lunch. The
292
+ spike never gets a month to run. Same bug. The only difference is *when they found
293
+ out*.
294
+
295
+ A report is something you remember to open. An error is something that finds you.
296
+ Buckets gives you the open measurement that makes the difference possible — every
297
+ read attributed, in real time, with no blind spots.
298
+
299
+ ### Get paged in Slack before the bill is in the post
300
+
301
+ This is the part that turns Buckets from a dashboard you remember to open into a
302
+ system that *finds you*. Connect Slack to [Crossdeck](https://cross-deck.com) and it
303
+ watches your buckets for you:
304
+
305
+ 1. **It learns your normal.** For ~7 days it quietly builds a baseline of what each
306
+ hour looks like — *per hour-of-day*, so a busy 2pm is judged against other 2pms,
307
+ never against 3am. No alerts during this; it's collecting.
308
+ 2. **It follows your fixes.** The baseline is recency-weighted: arrive bleeding 2M
309
+ reads, fix down to 50k, and it forgets the 2M and settles at your new normal. So
310
+ a later 50k→500k spike is a *real* deviation, not lost under an old number.
311
+ 3. **It pings only on a real surge.** When a completed hour breaks the baseline (a
312
+ big statistical jump *and* a meaningful multiple — both gates, so a tiny bucket
313
+ jittering never pages you), you get a Slack message:
314
+ > 🟡 **Read spike detected** — *512,000 reads* in the last hour, about *10×* your
315
+ > normal for this time of day (~50,000). Open Buckets to see which bucket moved.
316
+ 4. **You stay in control.** Shipped a feature you *know* adds reads? One click —
317
+ **"Expected — quiet for 24h"** — and it hushes while the baseline re-bases. Your
318
+ knowledge of your own roadmap is the final authority; it never pretends to know
319
+ better.
320
+
321
+ An ongoing spike pings **once**, not every hour. Cold-start means it never cries
322
+ wolf before it knows you. And — the rule that holds the whole system together — the
323
+ thing that watches your read bill **never runs one up**: every check reads a small
324
+ maintained summary, never scans your data.
325
+
326
+ > The open collector in this repo produces the buckets. The baseline, the anomaly
327
+ > detection, and the Slack alert are the layer [Crossdeck](https://cross-deck.com)
328
+ > builds on top. The collector stands alone, free, forever — Crossdeck is where the
329
+ > buckets start paging you.
330
+
331
+ ---
332
+
333
+ ## Counting model
334
+
335
+ | Operation | Counted as |
336
+ |---|---|
337
+ | Query returning N docs | N reads |
338
+ | Empty query | 1 read *(your provider bills a minimum of one)* |
339
+ | `doc.get()` | 1 read |
340
+ | `getAll(...refs)` | one read per ref |
341
+ | Write / delete | 1 each |
342
+
343
+ Counts are **defensible** — every one traces to a billed operation, so the rollup
344
+ reconciles against your provider's invoice instead of drifting from it.
345
+
346
+ ---
347
+
348
+ ## Datastore support
349
+
350
+ | Datastore | Status |
351
+ |---|---|
352
+ | **Google Cloud Firestore** (firebase-admin) | ✅ Supported |
353
+ | Postgres · DynamoDB · MongoDB | 🔜 Adapter interface is public — contributions welcome |
354
+
355
+ The trap *pattern* generalises to any driver with interceptable read methods, and
356
+ the storage `Sink` interface is datastore-agnostic. Firestore is the only adapter
357
+ we ship and support today — we'd rather support one datastore excellently than
358
+ five badly.
359
+
360
+ ---
361
+
362
+ ## API
363
+
364
+ ```ts
365
+ init({ apiKey, endpoint?, flushIntervalMs? }) // configure once; reports up to Crossdeck
366
+
367
+ bucket(name, fn) // ← the one verb you'll use: attribute everything inside to `name`
368
+
369
+ installFirestoreMeter(classes) // the Firestore trap (the only adapter today)
370
+ flush() // force a flush (tests / shutdown)
371
+
372
+ // lower-level, if you need it:
373
+ runWithCostTag(tag, fn) · enterCostTag(tag) · refineCostTag(patch) · currentCostTag()
374
+ recordReads(n) · recordWrites(n) · recordDeletes(n)
375
+ ```
376
+
377
+ One import to set up, one call to install, one verb to name a path. That's the
378
+ whole footprint. The reporting (collector → `POST /v1/buckets/report` → your
379
+ maintained rollup → dashboard) happens automatically — you never touch it.
380
+
381
+ ---
382
+
383
+ ## Roadmap
384
+
385
+ - [x] Core: tag · trap · meter · rollup
386
+ - [x] Firestore adapter
387
+ - [x] Public, versioned rollup schema
388
+ - [ ] Postgres adapter
389
+ - [ ] `compute` (invocation + CPU-ms) attribution in the public build
390
+ - [ ] OpenTelemetry export
391
+ - [ ] Community sink adapters (BigQuery, ClickHouse, S3)
392
+
393
+ The *intelligence* on top of these rollups — regression detection, deploy
394
+ attribution, forecasting, suggested fixes — is **not** on this roadmap by design.
395
+ That's the line between the open primitive and the product built on it.
396
+
397
+ ---
398
+
399
+ ## FAQ
400
+
401
+ **Is this really free, or is there a catch?**
402
+ Genuinely free, MIT, forever. The collector and the schema are the open primitive.
403
+ We make money on the *interpretation* layer (Crossdeck), not on locking up your
404
+ own cost data. Your rollups are yours, in your datastore, readable by anything.
405
+
406
+ **Won't patching the SDK slow down my reads?**
407
+ No. The overhead is one in-memory map increment per read. No I/O is added to the
408
+ read path; writes happen in a batched flush roughly once a minute.
409
+
410
+ **What if Buckets crashes or its sink is down?**
411
+ Your reads are unaffected — every wrapper returns the real result no matter what
412
+ happens inside the meter. You lose at most one ~60-second window of *counts*.
413
+
414
+ **Why not just read my cloud provider's billing export?**
415
+ Billing exports tell you the total. They can't tell you *which feature* or *which
416
+ user* — the attribution that lets you actually fix the cost. That's the entire
417
+ point of Buckets.
418
+
419
+ **Does it work with `firebase-functions` / serverless cold starts?**
420
+ Yes. The meter flushes on `SIGTERM` and `beforeExit`, so a scaling-to-zero
421
+ instance writes its final window before it dies.
422
+
423
+ ---
424
+
425
+ ## Contributing
426
+
427
+ Adapters, tests, and docs are the highest-leverage contributions. The bar for
428
+ anything touching the read path is the safety contract above — real-method-first,
429
+ never-throws, always-returns-untouched, with a test that proves it. See
430
+ [CONTRIBUTING.md](CONTRIBUTING.md).
431
+
432
+ ## Who's behind this
433
+
434
+ Buckets is built and battle-tested by **[Crossdeck](https://cross-deck.com)** —
435
+ revenue, analytics, identity, and cost intelligence for app developers. We run
436
+ Buckets in production on every read our own platform serves. If it's good enough
437
+ to protect our invoice, it's good enough for yours.
438
+
439
+ ## License
440
+
441
+ [MIT](LICENSE). Use it anywhere, including commercially. No attribution required
442
+ (though a ⭐ is always appreciated).
443
+
444
+ <div align="center">
445
+ <br>
446
+ <strong>Stop guessing what your database costs. Start knowing.</strong>
447
+ <br><br>
448
+ <code>npm install @cross-deck/buckets</code>
449
+ </div>
@@ -0,0 +1,41 @@
1
+ type AnyFn = (...args: any[]) => any;
2
+ /**
3
+ * The firebase-admin Firestore classes to patch. Pass the module namespace from
4
+ * `firebase-admin/firestore` — only the prototypes present are patched.
5
+ */
6
+ export interface FirestoreClasses {
7
+ Query?: {
8
+ prototype: {
9
+ get?: AnyFn;
10
+ };
11
+ };
12
+ DocumentReference?: {
13
+ prototype: {
14
+ get?: AnyFn;
15
+ };
16
+ };
17
+ Transaction?: {
18
+ prototype: {
19
+ get?: AnyFn;
20
+ getAll?: AnyFn;
21
+ };
22
+ };
23
+ Firestore?: {
24
+ prototype: {
25
+ getAll?: AnyFn;
26
+ };
27
+ };
28
+ }
29
+ /**
30
+ * Install the universal read meter on the firebase-admin Firestore classes. Call
31
+ * ONCE at process start, before any reads. Pass the namespace from
32
+ * `firebase-admin/firestore` so the exact prototypes the SDK uses are patched:
33
+ *
34
+ * import * as Firestore from "firebase-admin/firestore";
35
+ * installFirestoreMeter(Firestore);
36
+ */
37
+ export declare function installFirestoreMeter(classes: FirestoreClasses): void;
38
+ /** Test-only: reset the install guard so a suite can re-patch fresh prototypes. */
39
+ export declare function __resetFirestoreMeterForTests(): void;
40
+ export {};
41
+ //# sourceMappingURL=firestore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"firestore.d.ts","sourceRoot":"","sources":["../../src/adapters/firestore.ts"],"names":[],"mappings":"AA+BA,KAAK,KAAK,GAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAC;AAErC;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,KAAK,CAAC,EAAE;QAAE,SAAS,EAAE;YAAE,GAAG,CAAC,EAAE,KAAK,CAAA;SAAE,CAAA;KAAE,CAAC;IACvC,iBAAiB,CAAC,EAAE;QAAE,SAAS,EAAE;YAAE,GAAG,CAAC,EAAE,KAAK,CAAA;SAAE,CAAA;KAAE,CAAC;IACnD,WAAW,CAAC,EAAE;QAAE,SAAS,EAAE;YAAE,GAAG,CAAC,EAAE,KAAK,CAAC;YAAC,MAAM,CAAC,EAAE,KAAK,CAAA;SAAE,CAAA;KAAE,CAAC;IAC7D,SAAS,CAAC,EAAE;QAAE,SAAS,EAAE;YAAE,MAAM,CAAC,EAAE,KAAK,CAAA;SAAE,CAAA;KAAE,CAAC;CAC/C;AAwDD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,gBAAgB,GAAG,IAAI,CAsDrE;AAED,mFAAmF;AACnF,wBAAgB,6BAA6B,IAAI,IAAI,CAEpD"}
@@ -0,0 +1,145 @@
1
+ /**
2
+ * adapters/firestore — the universal Firestore read meter (the trap).
3
+ *
4
+ * THE LESSON (learned on a real product): per-call-site `recordReads()`
5
+ * instrumentation MISSES paths. You meter the read sites you're looking at and
6
+ * leave the cron / trigger / ingest path uncounted — often the majority of reads,
7
+ * invisible. Humans tag what they see and miss the path that matters.
8
+ *
9
+ * THE FIX: patch the admin SDK's read methods ONCE. From install onward, EVERY
10
+ * read — anywhere, on any code path — is counted under the ambient tag, with zero
11
+ * per-call-site work and no blind spots.
12
+ *
13
+ * SAFETY CONTRACT — this sits on your production read path, so it is defensive by
14
+ * construction. Each wrapper:
15
+ * 1. calls the REAL method first and captures the result,
16
+ * 2. counts in a try/catch that can never throw into the caller,
17
+ * 3. ALWAYS returns the real result, untouched.
18
+ * It cannot break a read, change a result, or add latency beyond one in-memory
19
+ * counter increment. A wrong count is a measurement error, never a correctness or
20
+ * availability one. Idempotent — calling it twice patches once.
21
+ *
22
+ * COUNTING MODEL — a query returning N docs = N reads (an empty result still bills
23
+ * 1, which the meter enforces). A document get = 1. getAll(...) = the ref count.
24
+ * CollectionReference.get IS Query.get (shared prototype method), so patching Query
25
+ * covers collections with no double-count.
26
+ */
27
+ import { recordReads } from "../cost-meter.js";
28
+ let installed = false;
29
+ /** `projects/{id}/…` → the project id, else undefined. Pure string op. */
30
+ function projectFromPath(path) {
31
+ const parts = path.split("/");
32
+ const i = parts.indexOf("projects");
33
+ return i >= 0 && parts[i + 1] ? parts[i + 1] : undefined;
34
+ }
35
+ /**
36
+ * Derive { collection, projectId } from the read target's path so an UNtagged read
37
+ * cascades to `col:<collection>` instead of "uncategorized". PURE CPU; never reads,
38
+ * never throws. Falls back to firebase-admin's internal `_queryOptions` for filtered
39
+ * queries (which don't expose `.path`).
40
+ */
41
+ function hintFrom(target) {
42
+ try {
43
+ const p = typeof target?.path === "string" ? target.path : "";
44
+ if (p) {
45
+ const parts = p.split("/").filter(Boolean);
46
+ const collection = parts.length % 2 === 0 ? parts[parts.length - 2] : parts[parts.length - 1];
47
+ return { collection, projectId: projectFromPath(p) };
48
+ }
49
+ const qo = target?._queryOptions;
50
+ if (qo) {
51
+ const collection = typeof qo.collectionId === "string" ? qo.collectionId : undefined;
52
+ const parent = typeof qo.parentPath?.relativeName === "string"
53
+ ? qo.parentPath.relativeName
54
+ : typeof qo.parentPath?.toString === "function"
55
+ ? String(qo.parentPath.toString())
56
+ : "";
57
+ return { collection, projectId: parent ? projectFromPath(parent) : undefined };
58
+ }
59
+ }
60
+ catch {
61
+ /* the meter must never throw */
62
+ }
63
+ return undefined;
64
+ }
65
+ function meterSnap(snap, hint) {
66
+ try {
67
+ const size = snap?.size;
68
+ recordReads(typeof size === "number" ? size : 1, hint);
69
+ }
70
+ catch {
71
+ /* best-effort */
72
+ }
73
+ }
74
+ function meterCount(n, hint) {
75
+ try {
76
+ recordReads(n, hint);
77
+ }
78
+ catch {
79
+ /* best-effort */
80
+ }
81
+ }
82
+ /**
83
+ * Install the universal read meter on the firebase-admin Firestore classes. Call
84
+ * ONCE at process start, before any reads. Pass the namespace from
85
+ * `firebase-admin/firestore` so the exact prototypes the SDK uses are patched:
86
+ *
87
+ * import * as Firestore from "firebase-admin/firestore";
88
+ * installFirestoreMeter(Firestore);
89
+ */
90
+ export function installFirestoreMeter(classes) {
91
+ if (installed)
92
+ return;
93
+ installed = true;
94
+ const { Query, DocumentReference, Transaction, Firestore } = classes;
95
+ // Query.get — covers Query AND CollectionReference (shared prototype method).
96
+ const qGet = Query?.prototype?.get;
97
+ if (qGet) {
98
+ Query.prototype.get = async function (...args) {
99
+ const snap = await qGet.apply(this, args);
100
+ meterSnap(snap, hintFrom(this));
101
+ return snap;
102
+ };
103
+ }
104
+ // DocumentReference.get — a single doc = 1 read.
105
+ const dGet = DocumentReference?.prototype?.get;
106
+ if (dGet) {
107
+ DocumentReference.prototype.get = async function (...args) {
108
+ const snap = await dGet.apply(this, args);
109
+ meterCount(1, hintFrom(this));
110
+ return snap;
111
+ };
112
+ }
113
+ // Transaction.get — query or doc; size when present, else 1.
114
+ const tGet = Transaction?.prototype?.get;
115
+ if (tGet) {
116
+ Transaction.prototype.get = async function (...args) {
117
+ const res = await tGet.apply(this, args);
118
+ meterSnap(res, hintFrom(args[0]));
119
+ return res;
120
+ };
121
+ }
122
+ // Transaction.getAll(...refs) — one read per ref.
123
+ const tGetAll = Transaction?.prototype?.getAll;
124
+ if (tGetAll) {
125
+ Transaction.prototype.getAll = async function (...args) {
126
+ const res = await tGetAll.apply(this, args);
127
+ meterCount(Array.isArray(res) ? res.length : args.length || 1, hintFrom(args[0]));
128
+ return res;
129
+ };
130
+ }
131
+ // Firestore.getAll(...refs) — batched doc reads.
132
+ const fGetAll = Firestore?.prototype?.getAll;
133
+ if (fGetAll) {
134
+ Firestore.prototype.getAll = async function (...args) {
135
+ const res = await fGetAll.apply(this, args);
136
+ meterCount(Array.isArray(res) ? res.length : args.length || 1, hintFrom(args[0]));
137
+ return res;
138
+ };
139
+ }
140
+ }
141
+ /** Test-only: reset the install guard so a suite can re-patch fresh prototypes. */
142
+ export function __resetFirestoreMeterForTests() {
143
+ installed = false;
144
+ }
145
+ //# sourceMappingURL=firestore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"firestore.js","sourceRoot":"","sources":["../../src/adapters/firestore.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,25 @@
1
+ export interface CostTag {
2
+ /** Optional coarse grouping (a caller-defined surface name). */
3
+ feature?: string;
4
+ /** The bucket name — what `bucket()` sets. Drives the report's `byLabel`. */
5
+ label?: string;
6
+ }
7
+ /** Run `fn` with `tag` bound for its entire async subtree. */
8
+ export declare function runWithCostTag<T>(tag: CostTag, fn: () => T): T;
9
+ /** Bind a tag for the remainder of the current async context (no closure to wrap). */
10
+ export declare function enterCostTag(tag: CostTag): void;
11
+ /** Refine the live tag in place (e.g. stamp a feature after the boundary). */
12
+ export declare function refineCostTag(patch: Partial<CostTag>): void;
13
+ /** The current tag, or a safe empty default outside any bound context. */
14
+ export declare function currentCostTag(): CostTag;
15
+ /**
16
+ * `bucket(name, fn)` — the headline verb, the `track()` of cost. Run `fn` with
17
+ * every operation inside it attributed to the bucket `name`; the attribution
18
+ * rides the async subtree automatically. The one verb most developers ever touch:
19
+ *
20
+ * await bucket("nightly-export", async () => {
21
+ * const rows = await db.collection("events").where(...).get(); // → "nightly-export"
22
+ * });
23
+ */
24
+ export declare function bucket<T>(name: string, fn: () => T): T;
25
+ //# sourceMappingURL=cost-context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cost-context.d.ts","sourceRoot":"","sources":["../src/cost-context.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,OAAO;IACtB,gEAAgE;IAChE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAKD,8DAA8D;AAC9D,wBAAgB,cAAc,CAAC,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAE9D;AAED,sFAAsF;AACtF,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAE/C;AAED,8EAA8E;AAC9E,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAG3D;AAED,0EAA0E;AAC1E,wBAAgB,cAAc,IAAI,OAAO,CAExC;AAED;;;;;;;;GAQG;AACH,wBAAgB,MAAM,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAEtD"}
@@ -0,0 +1,46 @@
1
+ /**
2
+ * cost-context — the request-scoped tag every counted operation attributes
3
+ * itself to. Set it ONCE at a boundary (or wrap a path with `bucket()`); it
4
+ * rides Node's AsyncLocalStorage down through every async fan-out, so one
5
+ * handler that triggers 15 reads attributes all 15 to the same bucket — with
6
+ * zero per-call-site work.
7
+ *
8
+ * Generic by design: unlike a hardcoded product taxonomy, the only meaningful
9
+ * field a consumer sets is the free-form `label` (the bucket name). `feature`
10
+ * is an optional coarse grouping if you want one; nothing here is
11
+ * Crossdeck-specific.
12
+ */
13
+ import { AsyncLocalStorage } from "node:async_hooks";
14
+ const DEFAULT_TAG = {};
15
+ const store = new AsyncLocalStorage();
16
+ /** Run `fn` with `tag` bound for its entire async subtree. */
17
+ export function runWithCostTag(tag, fn) {
18
+ return store.run({ ...tag }, fn);
19
+ }
20
+ /** Bind a tag for the remainder of the current async context (no closure to wrap). */
21
+ export function enterCostTag(tag) {
22
+ store.enterWith({ ...tag });
23
+ }
24
+ /** Refine the live tag in place (e.g. stamp a feature after the boundary). */
25
+ export function refineCostTag(patch) {
26
+ const cur = store.getStore();
27
+ if (cur)
28
+ Object.assign(cur, patch);
29
+ }
30
+ /** The current tag, or a safe empty default outside any bound context. */
31
+ export function currentCostTag() {
32
+ return store.getStore() ?? DEFAULT_TAG;
33
+ }
34
+ /**
35
+ * `bucket(name, fn)` — the headline verb, the `track()` of cost. Run `fn` with
36
+ * every operation inside it attributed to the bucket `name`; the attribution
37
+ * rides the async subtree automatically. The one verb most developers ever touch:
38
+ *
39
+ * await bucket("nightly-export", async () => {
40
+ * const rows = await db.collection("events").where(...).get(); // → "nightly-export"
41
+ * });
42
+ */
43
+ export function bucket(name, fn) {
44
+ return runWithCostTag({ ...currentCostTag(), label: name }, fn);
45
+ }
46
+ //# sourceMappingURL=cost-context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cost-context.js","sourceRoot":"","sources":["../src/cost-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AASrD,MAAM,WAAW,GAAY,EAAE,CAAC;AAChC,MAAM,KAAK,GAAG,IAAI,iBAAiB,EAAW,CAAC;AAE/C,8DAA8D;AAC9D,MAAM,UAAU,cAAc,CAAI,GAAY,EAAE,EAAW;IACzD,OAAO,KAAK,CAAC,GAAG,CAAC,EAAE,GAAG,GAAG,EAAE,EAAE,EAAE,CAAC,CAAC;AACnC,CAAC;AAED,sFAAsF;AACtF,MAAM,UAAU,YAAY,CAAC,GAAY;IACvC,KAAK,CAAC,SAAS,CAAC,EAAE,GAAG,GAAG,EAAE,CAAC,CAAC;AAC9B,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,aAAa,CAAC,KAAuB;IACnD,MAAM,GAAG,GAAG,KAAK,CAAC,QAAQ,EAAE,CAAC;IAC7B,IAAI,GAAG;QAAE,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;AACrC,CAAC;AAED,0EAA0E;AAC1E,MAAM,UAAU,cAAc;IAC5B,OAAO,KAAK,CAAC,QAAQ,EAAE,IAAI,WAAW,CAAC;AACzC,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,MAAM,CAAI,IAAY,EAAE,EAAW;IACjD,OAAO,cAAc,CAAC,EAAE,GAAG,cAAc,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;AAClE,CAAC"}
@@ -0,0 +1,28 @@
1
+ import type { Sink } from "./sink.js";
2
+ export type OpType = "read" | "write" | "delete";
3
+ /** Optional read-site hint — the collection touched, derived at the trap from the
4
+ * path. Lets an UNtagged read cascade to `col:<collection>` instead of vanishing. */
5
+ export interface CostHint {
6
+ collection?: string;
7
+ projectId?: string;
8
+ }
9
+ export interface MeterConfig {
10
+ sink: Sink;
11
+ flushIntervalMs?: number;
12
+ onError?: (e: unknown) => void;
13
+ }
14
+ /** Point the meter at a sink. Called by `init()`; pass your own sink to self-host. */
15
+ export declare function configureMeter(config: MeterConfig): void;
16
+ /** Count `n` ops of `op` against the live tag. Never throws. */
17
+ export declare function recordFirestore(op: OpType, n: number, hint?: CostHint): void;
18
+ /** Firestore bills a minimum of one read even for an empty result, so 0 counts as 1. */
19
+ export declare function recordReads(n: number, hint?: CostHint): void;
20
+ export declare function recordWrites(n?: number): void;
21
+ export declare function recordDeletes(n?: number): void;
22
+ /**
23
+ * Coalesce the buffer into one report per UTC day and hand each to the Sink.
24
+ * Snapshots + clears up front so concurrent records land in the next window.
25
+ * Never throws; a sink failure drops that window (surfaced via `onError`).
26
+ */
27
+ export declare function flush(): Promise<void>;
28
+ //# sourceMappingURL=cost-meter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cost-meter.d.ts","sourceRoot":"","sources":["../src/cost-meter.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,IAAI,EAA2B,MAAM,WAAW,CAAC;AAE/D,MAAM,MAAM,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;AAEjD;sFACsF;AACtF,MAAM,WAAW,QAAQ;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAmBD,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,IAAI,CAAC;IACX,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;CAChC;AAED,sFAAsF;AACtF,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG,IAAI,CAIxD;AAeD,gEAAgE;AAChE,wBAAgB,eAAe,CAAC,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,QAAQ,GAAG,IAAI,CAkB5E;AAED,wFAAwF;AACxF,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,QAAQ,GAAG,IAAI,CAE5D;AACD,wBAAgB,YAAY,CAAC,CAAC,SAAI,GAAG,IAAI,CAExC;AACD,wBAAgB,aAAa,CAAC,CAAC,SAAI,GAAG,IAAI,CAEzC;AAOD;;;;GAIG;AACH,wBAAsB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CA2C3C"}
@@ -0,0 +1,137 @@
1
+ /**
2
+ * cost-meter — counts operations against the ambient tag and flushes them to the
3
+ * configured Sink cheaply.
4
+ *
5
+ * LOW-OVERHEAD CONTRACT (the thing that warns you about reads must not run them
6
+ * up): counts accumulate in an in-memory buffer and flush periodically — NEVER one
7
+ * network call per counted operation. A flush coalesces the whole window into one
8
+ * report per UTC day and hands it to the Sink. At steady state that is ~1 small
9
+ * request a minute, regardless of how many ops you served.
10
+ *
11
+ * BEST-EFFORT CONTRACT: metering must never throw into your code. Every recorder
12
+ * swallows its own errors; a failed flush drops that window's counts (surfaced via
13
+ * `onError` if you pass one) rather than disturbing the app.
14
+ */
15
+ import { currentCostTag } from "./cost-context.js";
16
+ // NUL separator — a bucket/collection name can contain almost anything except
17
+ // this, so the key never collides with a name that has a "|" or ":" in it.
18
+ const SEP = "\u001f"; // ASCII Unit Separator
19
+ /** key = date <NUL> op <NUL> label → count */
20
+ const labelBuffer = new Map();
21
+ /** key = date <NUL> op <NUL> hour → count */
22
+ const hourBuffer = new Map();
23
+ let sink = null;
24
+ let flushIntervalMs = 60_000;
25
+ let onError = null;
26
+ let timer = null;
27
+ let flushing = false;
28
+ /** Safety valve — flush early if a burst fills the buffer between intervals. */
29
+ const MAX_BUFFER_KEYS = 5_000;
30
+ /** Point the meter at a sink. Called by `init()`; pass your own sink to self-host. */
31
+ export function configureMeter(config) {
32
+ sink = config.sink;
33
+ if (config.flushIntervalMs && config.flushIntervalMs > 0)
34
+ flushIntervalMs = config.flushIntervalMs;
35
+ onError = config.onError ?? null;
36
+ }
37
+ const utcDate = () => new Date().toISOString().slice(0, 10);
38
+ const utcHour = () => new Date().toISOString().slice(11, 13);
39
+ function ensureFlushLoop() {
40
+ if (timer)
41
+ return;
42
+ timer = setInterval(() => void flush(), flushIntervalMs);
43
+ // Don't keep the event loop alive just for metering.
44
+ timer.unref?.();
45
+ // Flush the last window on shutdown.
46
+ process.once?.("SIGTERM", () => void flush());
47
+ process.once?.("beforeExit", () => void flush());
48
+ }
49
+ /** Count `n` ops of `op` against the live tag. Never throws. */
50
+ export function recordFirestore(op, n, hint) {
51
+ try {
52
+ if (!Number.isFinite(n) || n <= 0)
53
+ return;
54
+ const t = currentCostTag();
55
+ const date = utcDate();
56
+ // CASCADE — every op gets a label, by design (no blind spots): the bucket name
57
+ // wins; else the collection it actually touched (`col:posts`); else
58
+ // "uncategorized" as a loud last resort. A read is never invisible.
59
+ const label = t.label || (hint?.collection ? `col:${hint.collection}` : "uncategorized");
60
+ const lk = date + SEP + op + SEP + label;
61
+ labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + n);
62
+ const hk = date + SEP + op + SEP + utcHour();
63
+ hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + n);
64
+ ensureFlushLoop();
65
+ if (labelBuffer.size + hourBuffer.size > MAX_BUFFER_KEYS)
66
+ void flush();
67
+ }
68
+ catch {
69
+ /* metering is best-effort — never disturb the caller */
70
+ }
71
+ }
72
+ /** Firestore bills a minimum of one read even for an empty result, so 0 counts as 1. */
73
+ export function recordReads(n, hint) {
74
+ recordFirestore("read", Math.max(n, 1), hint);
75
+ }
76
+ export function recordWrites(n = 1) {
77
+ recordFirestore("write", n);
78
+ }
79
+ export function recordDeletes(n = 1) {
80
+ recordFirestore("delete", n);
81
+ }
82
+ function add(target, key, op, n) {
83
+ const bag = (target[key] ??= {});
84
+ bag[op] = (bag[op] ?? 0) + n;
85
+ }
86
+ /**
87
+ * Coalesce the buffer into one report per UTC day and hand each to the Sink.
88
+ * Snapshots + clears up front so concurrent records land in the next window.
89
+ * Never throws; a sink failure drops that window (surfaced via `onError`).
90
+ */
91
+ export async function flush() {
92
+ if (flushing)
93
+ return;
94
+ // Not configured (init not called) — drop, don't grow unbounded.
95
+ if (!sink) {
96
+ labelBuffer.clear();
97
+ hourBuffer.clear();
98
+ return;
99
+ }
100
+ if (labelBuffer.size === 0 && hourBuffer.size === 0)
101
+ return;
102
+ flushing = true;
103
+ const labels = new Map(labelBuffer);
104
+ const hours = new Map(hourBuffer);
105
+ labelBuffer.clear();
106
+ hourBuffer.clear();
107
+ try {
108
+ const byDate = new Map();
109
+ const reportFor = (date) => {
110
+ let r = byDate.get(date);
111
+ if (!r) {
112
+ r = { date, byLabel: {}, byHour: {} };
113
+ byDate.set(date, r);
114
+ }
115
+ return r;
116
+ };
117
+ for (const [k, n] of labels) {
118
+ const [date, op, label] = k.split(SEP);
119
+ add(reportFor(date).byLabel, label, op, n);
120
+ }
121
+ for (const [k, n] of hours) {
122
+ const [date, op, hour] = k.split(SEP);
123
+ add(reportFor(date).byHour, hour, op, n);
124
+ }
125
+ for (const report of byDate.values()) {
126
+ await sink.flush(report);
127
+ }
128
+ }
129
+ catch (e) {
130
+ // Drop this window rather than risk a partial/double report on retry.
131
+ onError?.(e);
132
+ }
133
+ finally {
134
+ flushing = false;
135
+ }
136
+ }
137
+ //# sourceMappingURL=cost-meter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cost-meter.js","sourceRoot":"","sources":["../src/cost-meter.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @cross-deck/buckets — know exactly what every database read costs you, and who
3
+ * caused it. A tiny, never-throws collector for Firestore.
4
+ *
5
+ * The whole footprint a consumer sees:
6
+ * 1. init({ apiKey, firestore }) — configure once, install the trap once
7
+ * 2. bucket(name, fn) — name the read paths that matter
8
+ * 3. (the dashboard shows the rest — and names the ones you haven't yet)
9
+ */
10
+ import { type MeterConfig } from "./cost-meter.js";
11
+ import { type Sink } from "./sink.js";
12
+ import { type FirestoreClasses } from "./adapters/firestore.js";
13
+ export interface InitOptions {
14
+ /** The project's `cd_sk_` SECRET key. Server-to-server only — never a browser key. */
15
+ apiKey: string;
16
+ /**
17
+ * Pass the namespace from `firebase-admin/firestore` to auto-install the read
18
+ * trap (recommended — this is what makes every read count with no per-call work).
19
+ * Omit it if you'd rather call `installFirestoreMeter()` yourself, or you only
20
+ * use the manual `recordReads()` recorders.
21
+ */
22
+ firestore?: FirestoreClasses;
23
+ /** Override the report endpoint (defaults to Crossdeck's ingest). */
24
+ endpoint?: string;
25
+ /** How often to flush coalesced counts (ms). Default 60_000. */
26
+ flushIntervalMs?: number;
27
+ /** Bring your own sink (self-host the rollups). Defaults to reporting up to Crossdeck. */
28
+ sink?: Sink;
29
+ /** Notified when a flush fails, so a dropped window is never silent. Best-effort. */
30
+ onError?: MeterConfig["onError"];
31
+ }
32
+ /**
33
+ * Configure Buckets once, at process start. Points the meter at a sink (Crossdeck's
34
+ * ingest by default) and — if you pass `firestore` — installs the universal read
35
+ * trap so every read counts automatically.
36
+ */
37
+ export declare function init(options: InitOptions): void;
38
+ /** Alias — reads well next to `bucket()` at a call site. */
39
+ export { init as initBuckets };
40
+ export { bucket, runWithCostTag, enterCostTag, refineCostTag, currentCostTag, type CostTag, } from "./cost-context.js";
41
+ export { recordReads, recordWrites, recordDeletes, flush, type CostHint, type OpType, type MeterConfig, } from "./cost-meter.js";
42
+ export { installFirestoreMeter, type FirestoreClasses } from "./adapters/firestore.js";
43
+ export { ReportSink, type Sink, type BucketsReport, type OpCounts, type ReportSinkConfig } from "./sink.js";
44
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AACnE,OAAO,EAAc,KAAK,IAAI,EAAE,MAAM,WAAW,CAAC;AAClD,OAAO,EAAyB,KAAK,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAEvF,MAAM,WAAW,WAAW;IAC1B,sFAAsF;IACtF,MAAM,EAAE,MAAM,CAAC;IACf;;;;;OAKG;IACH,SAAS,CAAC,EAAE,gBAAgB,CAAC;IAC7B,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,0FAA0F;IAC1F,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,qFAAqF;IACrF,OAAO,CAAC,EAAE,WAAW,CAAC,SAAS,CAAC,CAAC;CAClC;AAED;;;;GAIG;AACH,wBAAgB,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,CAI/C;AAED,4DAA4D;AAC5D,OAAO,EAAE,IAAI,IAAI,WAAW,EAAE,CAAC;AAG/B,OAAO,EACL,MAAM,EACN,cAAc,EACd,YAAY,EACZ,aAAa,EACb,cAAc,EACd,KAAK,OAAO,GACb,MAAM,mBAAmB,CAAC;AAG3B,OAAO,EACL,WAAW,EACX,YAAY,EACZ,aAAa,EACb,KAAK,EACL,KAAK,QAAQ,EACb,KAAK,MAAM,EACX,KAAK,WAAW,GACjB,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EAAE,qBAAqB,EAAE,KAAK,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAGvF,OAAO,EAAE,UAAU,EAAE,KAAK,IAAI,EAAE,KAAK,aAAa,EAAE,KAAK,QAAQ,EAAE,KAAK,gBAAgB,EAAE,MAAM,WAAW,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @cross-deck/buckets — know exactly what every database read costs you, and who
3
+ * caused it. A tiny, never-throws collector for Firestore.
4
+ *
5
+ * The whole footprint a consumer sees:
6
+ * 1. init({ apiKey, firestore }) — configure once, install the trap once
7
+ * 2. bucket(name, fn) — name the read paths that matter
8
+ * 3. (the dashboard shows the rest — and names the ones you haven't yet)
9
+ */
10
+ import { configureMeter } from "./cost-meter.js";
11
+ import { ReportSink } from "./sink.js";
12
+ import { installFirestoreMeter } from "./adapters/firestore.js";
13
+ /**
14
+ * Configure Buckets once, at process start. Points the meter at a sink (Crossdeck's
15
+ * ingest by default) and — if you pass `firestore` — installs the universal read
16
+ * trap so every read counts automatically.
17
+ */
18
+ export function init(options) {
19
+ const sink = options.sink ?? new ReportSink({ apiKey: options.apiKey, endpoint: options.endpoint });
20
+ configureMeter({ sink, flushIntervalMs: options.flushIntervalMs, onError: options.onError });
21
+ if (options.firestore)
22
+ installFirestoreMeter(options.firestore);
23
+ }
24
+ /** Alias — reads well next to `bucket()` at a call site. */
25
+ export { init as initBuckets };
26
+ // The headline verb + the lower-level tag controls it is sugar over.
27
+ export { bucket, runWithCostTag, enterCostTag, refineCostTag, currentCostTag, } from "./cost-context.js";
28
+ // Manual recorders (for non-Firestore ops, or when you don't install the trap).
29
+ export { recordReads, recordWrites, recordDeletes, flush, } from "./cost-meter.js";
30
+ // The trap (the only datastore adapter today) + its class shape.
31
+ export { installFirestoreMeter } from "./adapters/firestore.js";
32
+ // The sink seam — for self-hosting rollups instead of reporting to Crossdeck.
33
+ export { ReportSink } from "./sink.js";
34
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,cAAc,EAAoB,MAAM,iBAAiB,CAAC;AACnE,OAAO,EAAE,UAAU,EAAa,MAAM,WAAW,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAyB,MAAM,yBAAyB,CAAC;AAsBvF;;;;GAIG;AACH,MAAM,UAAU,IAAI,CAAC,OAAoB;IACvC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,IAAI,UAAU,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC;IACpG,cAAc,CAAC,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,CAAC,eAAe,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IAC7F,IAAI,OAAO,CAAC,SAAS;QAAE,qBAAqB,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;AAClE,CAAC;AAED,4DAA4D;AAC5D,OAAO,EAAE,IAAI,IAAI,WAAW,EAAE,CAAC;AAE/B,qEAAqE;AACrE,OAAO,EACL,MAAM,EACN,cAAc,EACd,YAAY,EACZ,aAAa,EACb,cAAc,GAEf,MAAM,mBAAmB,CAAC;AAE3B,gFAAgF;AAChF,OAAO,EACL,WAAW,EACX,YAAY,EACZ,aAAa,EACb,KAAK,GAIN,MAAM,iBAAiB,CAAC;AAEzB,iEAAiE;AACjE,OAAO,EAAE,qBAAqB,EAAyB,MAAM,yBAAyB,CAAC;AAEvF,8EAA8E;AAC9E,OAAO,EAAE,UAAU,EAAuE,MAAM,WAAW,CAAC"}
package/dist/sink.d.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * sink — where the meter sends a coalesced rollup, and the wire shape it sends.
3
+ *
4
+ * Abstracting the sink is what makes Buckets storage-agnostic: the meter never
5
+ * knows where counts go. The DEFAULT sink (`ReportSink`) reports up to Crossdeck's
6
+ * ingest endpoint so the numbers surface on your Crossdeck dashboard. A team that
7
+ * wants to self-host can implement `Sink` against anything (Postgres, a file, your
8
+ * own API) without touching the meter.
9
+ */
10
+ export interface OpCounts {
11
+ read?: number;
12
+ write?: number;
13
+ delete?: number;
14
+ }
15
+ /**
16
+ * One coalesced report — the wire contract (see docs/ROLLUP_SCHEMA.md). The meter
17
+ * produces one of these per UTC day in a flush window (usually exactly one).
18
+ */
19
+ export interface BucketsReport {
20
+ /** UTC day "YYYY-MM-DD". */
21
+ date: string;
22
+ /** bucket name → counts. The heart of the report. */
23
+ byLabel: Record<string, OpCounts>;
24
+ /** UTC hour "HH" → counts, for the hourly "did my fix land this hour?" view. */
25
+ byHour?: Record<string, OpCounts>;
26
+ }
27
+ /**
28
+ * A destination for coalesced rollups. `flush` MAY throw on failure — the meter
29
+ * catches it, drops that one window, and never lets it reach your app.
30
+ */
31
+ export interface Sink {
32
+ flush(report: BucketsReport): Promise<void>;
33
+ }
34
+ export interface ReportSinkConfig {
35
+ /** The project's `cd_sk_` secret key. Server-to-server only. */
36
+ apiKey: string;
37
+ /** Defaults to https://api.cross-deck.com/v1/buckets/report */
38
+ endpoint?: string;
39
+ /** Request timeout (ms); a slow Crossdeck must never stall your flush. */
40
+ timeoutMs?: number;
41
+ }
42
+ /**
43
+ * The default sink: POST one coalesced rollup to Crossdeck's ingest endpoint.
44
+ * The ingest folds it into the day's maintained doc with `increment`, so many
45
+ * reports a minute coalesce safely. This path does ZERO database reads — it sends
46
+ * a summary, it does not read. Throws on a non-202 so the meter can log/drop the
47
+ * window; the meter guarantees it never reaches your app.
48
+ */
49
+ export declare class ReportSink implements Sink {
50
+ private readonly endpoint;
51
+ private readonly apiKey;
52
+ private readonly timeoutMs;
53
+ constructor(config: ReportSinkConfig);
54
+ flush(report: BucketsReport): Promise<void>;
55
+ }
56
+ //# sourceMappingURL=sink.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sink.d.ts","sourceRoot":"","sources":["../src/sink.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,WAAW,QAAQ;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,4BAA4B;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IAClC,gFAAgF;IAChF,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;CACnC;AAED;;;GAGG;AACH,MAAM,WAAW,IAAI;IACnB,KAAK,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7C;AAED,MAAM,WAAW,gBAAgB;IAC/B,gEAAgE;IAChE,MAAM,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,0EAA0E;IAC1E,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAID;;;;;;GAMG;AACH,qBAAa,UAAW,YAAW,IAAI;IACrC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;gBAEvB,MAAM,EAAE,gBAAgB;IAM9B,KAAK,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;CAclD"}
package/dist/sink.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * sink — where the meter sends a coalesced rollup, and the wire shape it sends.
3
+ *
4
+ * Abstracting the sink is what makes Buckets storage-agnostic: the meter never
5
+ * knows where counts go. The DEFAULT sink (`ReportSink`) reports up to Crossdeck's
6
+ * ingest endpoint so the numbers surface on your Crossdeck dashboard. A team that
7
+ * wants to self-host can implement `Sink` against anything (Postgres, a file, your
8
+ * own API) without touching the meter.
9
+ */
10
+ const DEFAULT_ENDPOINT = "https://api.cross-deck.com/v1/buckets/report";
11
+ /**
12
+ * The default sink: POST one coalesced rollup to Crossdeck's ingest endpoint.
13
+ * The ingest folds it into the day's maintained doc with `increment`, so many
14
+ * reports a minute coalesce safely. This path does ZERO database reads — it sends
15
+ * a summary, it does not read. Throws on a non-202 so the meter can log/drop the
16
+ * window; the meter guarantees it never reaches your app.
17
+ */
18
+ export class ReportSink {
19
+ endpoint;
20
+ apiKey;
21
+ timeoutMs;
22
+ constructor(config) {
23
+ this.endpoint = config.endpoint ?? DEFAULT_ENDPOINT;
24
+ this.apiKey = config.apiKey;
25
+ this.timeoutMs = config.timeoutMs ?? 5000;
26
+ }
27
+ async flush(report) {
28
+ const res = await fetch(this.endpoint, {
29
+ method: "POST",
30
+ signal: AbortSignal.timeout(this.timeoutMs),
31
+ headers: {
32
+ "content-type": "application/json",
33
+ authorization: `Bearer ${this.apiKey}`,
34
+ },
35
+ body: JSON.stringify(report),
36
+ });
37
+ if (res.status !== 202) {
38
+ throw new Error(`Buckets report rejected: HTTP ${res.status}`);
39
+ }
40
+ }
41
+ }
42
+ //# sourceMappingURL=sink.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sink.js","sourceRoot":"","sources":["../src/sink.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAsCH,MAAM,gBAAgB,GAAG,8CAA8C,CAAC;AAExE;;;;;;GAMG;AACH,MAAM,OAAO,UAAU;IACJ,QAAQ,CAAS;IACjB,MAAM,CAAS;IACf,SAAS,CAAS;IAEnC,YAAY,MAAwB;QAClC,IAAI,CAAC,QAAQ,GAAG,MAAM,CAAC,QAAQ,IAAI,gBAAgB,CAAC;QACpD,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;QAC5B,IAAI,CAAC,SAAS,GAAG,MAAM,CAAC,SAAS,IAAI,IAAI,CAAC;IAC5C,CAAC;IAED,KAAK,CAAC,KAAK,CAAC,MAAqB;QAC/B,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,IAAI,CAAC,QAAQ,EAAE;YACrC,MAAM,EAAE,MAAM;YACd,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC;YAC3C,OAAO,EAAE;gBACP,cAAc,EAAE,kBAAkB;gBAClC,aAAa,EAAE,UAAU,IAAI,CAAC,MAAM,EAAE;aACvC;YACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC;SAC7B,CAAC,CAAC;QACH,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;YACvB,MAAM,IAAI,KAAK,CAAC,iCAAiC,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;QACjE,CAAC;IACH,CAAC;CACF"}
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@cross-deck/buckets",
3
+ "version": "0.1.0",
4
+ "description": "Know exactly what every database read costs you — and who caused it. A tiny, never-throws read-cost collector for Firestore.",
5
+ "license": "MIT",
6
+ "author": "Crossdeck",
7
+ "type": "module",
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ },
15
+ "./adapters/firestore": {
16
+ "types": "./dist/adapters/firestore.d.ts",
17
+ "import": "./dist/adapters/firestore.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "sideEffects": false,
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.json",
28
+ "test": "vitest run",
29
+ "test:watch": "vitest",
30
+ "typecheck": "tsc -p tsconfig.json --noEmit",
31
+ "prepare": "npm run build",
32
+ "prepublishOnly": "npm run build"
33
+ },
34
+ "keywords": [
35
+ "firestore",
36
+ "firebase",
37
+ "cost",
38
+ "observability",
39
+ "database",
40
+ "reads",
41
+ "attribution",
42
+ "finops",
43
+ "crossdeck"
44
+ ],
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/Crossdeckhq/buckets-oss.git"
48
+ },
49
+ "homepage": "https://github.com/Crossdeckhq/buckets-oss#readme",
50
+ "bugs": {
51
+ "url": "https://github.com/Crossdeckhq/buckets-oss/issues"
52
+ },
53
+ "peerDependencies": {
54
+ "firebase-admin": ">=11"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "firebase-admin": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "devDependencies": {
62
+ "@types/node": "^20.11.0",
63
+ "firebase-admin": "^12.0.0",
64
+ "typescript": "^5.4.0",
65
+ "vitest": "^1.6.0"
66
+ },
67
+ "engines": {
68
+ "node": ">=18"
69
+ }
70
+ }