@drawbridge/drawbridge-utils 0.0.70 → 0.0.72

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/billing.cjs CHANGED
@@ -277,9 +277,35 @@ var scrapeFee = ({ provider, bytes, requests, url, cold }) => {
277
277
  }
278
278
  return LOCAL_FLAT_CENTS;
279
279
  };
280
+ var scrapeBreakdown = ({ provider, bytes, requests, url }) => {
281
+ if (provider === "brightdata") {
282
+ return {
283
+ provider,
284
+ basis: "bytes",
285
+ units: bytes || 0,
286
+ rate: ratePerGB(url),
287
+ rateUnit: "GB",
288
+ cents: scrapeFee({ provider, bytes, url, cold: true })
289
+ };
290
+ }
291
+ if (provider === "brightdata-unlocker") {
292
+ return {
293
+ provider,
294
+ basis: "requests",
295
+ units: requests || 0,
296
+ // cpmCents is cents-per-request; ×10 expresses it as dollars per 1,000.
297
+ rate: cpmCents(url) * 10,
298
+ rateUnit: "1K requests",
299
+ cents: scrapeFee({ provider, requests, url, cold: true })
300
+ };
301
+ }
302
+ return { provider: provider || "local", basis: "local", units: null, rate: null, rateUnit: null, cents: LOCAL_FLAT_CENTS };
303
+ };
280
304
  var scrape = {
281
305
  // Cents for a scrape given its provider/bytes/cold basis.
282
306
  fee: scrapeFee,
307
+ // Itemized cold-fee breakdown (basis/units/rate/cents) for display + the scrape doc.
308
+ breakdown: scrapeBreakdown,
283
309
  // Deferred bill(scrapeId) the scrape store's resolve() hands the generation
284
310
  // flow. Atomically claims the cold (first-time) fee via the `charged` flag so
285
311
  // a BrightData scrape's metered cost is recovered ONCE; later reuses pay the
@@ -361,6 +387,63 @@ var action = {
361
387
  await incrementUsageTotals({ controller, usageId, $inc: { "totals.actions": amount }, session });
362
388
  }
363
389
  },
390
+ // Batch variant of record() for a caller writing many ledger rows inside ONE
391
+ // transaction. A session forbids concurrent ops, so the alternative is a serial
392
+ // await-loop costing N round-trips; this does a single bulkWrite of idempotent
393
+ // { usage, key } upserts (ordered:false), then bumps usage.totals.actions by the
394
+ // summed units of ONLY the genuinely-inserted rows (upsertedIds) — mirroring
395
+ // record()'s per-row `inserted` gate. Rows are deduped by key within the batch
396
+ // so two upserts can't target the same { usage, key }. Pass `session` to join
397
+ // the caller's transaction (the intended path).
398
+ bulk: async ({ controller, organization, usageId, rows = [], session }) => {
399
+ if (!controller || !organization || !usageId || !Array.isArray(rows) || rows.length === 0) return;
400
+ const seen = /* @__PURE__ */ new Set();
401
+ const valid = [];
402
+ for (const row of rows) {
403
+ const { type, units = 1, key, meta = {}, traceId } = row || {};
404
+ const amount2 = Number(units);
405
+ if (!type || !key || !Number.isFinite(amount2) || amount2 === 0) continue;
406
+ if (seen.has(key)) continue;
407
+ seen.add(key);
408
+ valid.push({ type, amount: amount2, key, meta, traceId });
409
+ }
410
+ if (valid.length === 0) return;
411
+ const result = await controller.bulk({
412
+ collection: "action",
413
+ pipeline: valid.map(({ type, amount: amount2, key, meta, traceId }) => ({
414
+ updateOne: {
415
+ filter: { key, usage: usageId },
416
+ update: {
417
+ $setOnInsert: {
418
+ organization,
419
+ usage: usageId,
420
+ type,
421
+ units: amount2,
422
+ key,
423
+ meta,
424
+ ...traceId && { traceId }
425
+ }
426
+ },
427
+ upsert: true
428
+ }
429
+ })),
430
+ options: {
431
+ bypassDocumentValidation: true,
432
+ ordered: false,
433
+ ...session && { session }
434
+ }
435
+ });
436
+ const amount = Object.keys((result == null ? void 0 : result.upsertedIds) || {}).reduce(
437
+ (sum, index) => {
438
+ var _a;
439
+ return sum + (((_a = valid[Number(index)]) == null ? void 0 : _a.amount) || 0);
440
+ },
441
+ 0
442
+ );
443
+ if (amount > 0) {
444
+ await incrementUsageTotals({ controller, usageId, $inc: { "totals.actions": amount }, session });
445
+ }
446
+ },
364
447
  // Authoritative per-period total to bill — sum(units) over the period's ledger
365
448
  // rows. The Stripe meter emit (in the Stripe/sync layer) reports this number.
366
449
  sum: async ({ controller, usageId }) => {
@@ -1,5 +1,5 @@
1
1
  import { credit, debit } from './transactions.cjs';
2
- import { isDuplicateKey, incrementUsageTotals } from './usage.cjs';
2
+ import { incrementUsageTotals, isDuplicateKey } from './usage.cjs';
3
3
  import { createLogger } from '@drawbridge/drawbridge-telemetry';
4
4
 
5
5
  // Billing — the single source of truth for what Drawbridge charges, and the
@@ -9,7 +9,7 @@ import { createLogger } from '@drawbridge/drawbridge-telemetry';
9
9
  // billing.ai { price, cost, bill } AI requests → credit rail (debit)
10
10
  // billing.scrape { fee, bill } scrape fees → credit rail (debit)
11
11
  // billing.grant { bonus, admin } promo credits → credit rail (credit)
12
- // billing.action { record, sum } usage actions → Stripe meter rail
12
+ // billing.action { record, bulk, sum } usage actions → Stripe meter rail
13
13
  //
14
14
  // Sits on top of transactions.js (the generic credit/debit money rail) and
15
15
  // usage.js (the action-ledger + usage-totals primitives). Shopify order fees are
@@ -226,11 +226,53 @@ const scrapeFee = ({ provider, bytes, requests, url, cold }) => {
226
226
 
227
227
  };
228
228
 
229
+ // Itemized cold-fee breakdown for the scrape doc / admin dashboard: the same cents
230
+ // scrapeFee charges on a COLD scrape, plus the basis, the metered units, and the
231
+ // wholesale provider rate behind it. Reuse hits aren't represented here — the doc
232
+ // records its cold acquisition cost. `rate` is the wholesale rate in dollars per
233
+ // `rateUnit` (pre-markup, i.e. the BrightData list price); `cents` is the retail fee
234
+ // billed (incl MARKUP). Local renders have no external cost — flat, no rate.
235
+ const scrapeBreakdown = ({ provider, bytes, requests, url }) => {
236
+
237
+ if( provider === 'brightdata' ){
238
+
239
+ return {
240
+ provider,
241
+ basis : 'bytes',
242
+ units : bytes || 0,
243
+ rate : ratePerGB( url ),
244
+ rateUnit : 'GB',
245
+ cents : scrapeFee({ provider, bytes, url, cold : true })
246
+ };
247
+
248
+ }
249
+
250
+ if( provider === 'brightdata-unlocker' ){
251
+
252
+ return {
253
+ provider,
254
+ basis : 'requests',
255
+ units : requests || 0,
256
+ // cpmCents is cents-per-request; ×10 expresses it as dollars per 1,000.
257
+ rate : cpmCents( url ) * 10,
258
+ rateUnit : '1K requests',
259
+ cents : scrapeFee({ provider, requests, url, cold : true })
260
+ };
261
+
262
+ }
263
+
264
+ return { provider : provider || 'local', basis : 'local', units : null, rate : null, rateUnit : null, cents : LOCAL_FLAT_CENTS };
265
+
266
+ };
267
+
229
268
  const scrape = {
230
269
 
231
270
  // Cents for a scrape given its provider/bytes/cold basis.
232
271
  fee : scrapeFee,
233
272
 
273
+ // Itemized cold-fee breakdown (basis/units/rate/cents) for display + the scrape doc.
274
+ breakdown : scrapeBreakdown,
275
+
234
276
  // Deferred bill(scrapeId) the scrape store's resolve() hands the generation
235
277
  // flow. Atomically claims the cold (first-time) fee via the `charged` flag so
236
278
  // a BrightData scrape's metered cost is recovered ONCE; later reuses pay the
@@ -357,6 +399,76 @@ const action = {
357
399
 
358
400
  },
359
401
 
402
+ // Batch variant of record() for a caller writing many ledger rows inside ONE
403
+ // transaction. A session forbids concurrent ops, so the alternative is a serial
404
+ // await-loop costing N round-trips; this does a single bulkWrite of idempotent
405
+ // { usage, key } upserts (ordered:false), then bumps usage.totals.actions by the
406
+ // summed units of ONLY the genuinely-inserted rows (upsertedIds) — mirroring
407
+ // record()'s per-row `inserted` gate. Rows are deduped by key within the batch
408
+ // so two upserts can't target the same { usage, key }. Pass `session` to join
409
+ // the caller's transaction (the intended path).
410
+ bulk : async ({ controller, organization, usageId, rows = [], session }) => {
411
+
412
+ if( ! controller || ! organization || ! usageId || ! Array.isArray( rows ) || rows.length === 0 ) return;
413
+
414
+ const seen = new Set();
415
+ const valid = [];
416
+
417
+ for( const row of rows ){
418
+
419
+ const { type, units = 1, key, meta = {}, traceId } = row || {};
420
+ const amount = Number( units );
421
+
422
+ if( ! type || ! key || ! Number.isFinite( amount ) || amount === 0 ) continue;
423
+ if( seen.has( key ) ) continue;
424
+
425
+ seen.add( key );
426
+ valid.push({ type, amount, key, meta, traceId });
427
+
428
+ }
429
+
430
+ if( valid.length === 0 ) return;
431
+
432
+ const result = await controller.bulk({
433
+ collection : 'action',
434
+ pipeline : valid.map( ({ type, amount, key, meta, traceId }) => ({
435
+ updateOne : {
436
+ filter : { key, usage : usageId },
437
+ update : {
438
+ $setOnInsert : {
439
+ organization,
440
+ usage : usageId,
441
+ type,
442
+ units : amount,
443
+ key,
444
+ meta,
445
+ ...( traceId && { traceId })
446
+ }
447
+ },
448
+ upsert : true
449
+ }
450
+ })),
451
+ options : {
452
+ bypassDocumentValidation : true,
453
+ ordered : false,
454
+ ...( session && { session })
455
+ }
456
+ });
457
+
458
+ // upsertedIds = { <opIndex>: _id } for newly-inserted rows; sum their units.
459
+ const amount = Object.keys( result?.upsertedIds || {} ).reduce(
460
+ ( sum, index ) => sum + ( valid[ Number( index ) ]?.amount || 0 ),
461
+ 0
462
+ );
463
+
464
+ if( amount > 0 ){
465
+
466
+ await incrementUsageTotals({ controller, usageId, $inc : { 'totals.actions' : amount }, session });
467
+
468
+ }
469
+
470
+ },
471
+
360
472
  // Authoritative per-period total to bill — sum(units) over the period's ledger
361
473
  // rows. The Stripe meter emit (in the Stripe/sync layer) reports this number.
362
474
  sum : async ({ controller, usageId }) => {
package/dist/billing.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { credit, debit } from './transactions.js';
2
- import { isDuplicateKey, incrementUsageTotals } from './usage.js';
2
+ import { incrementUsageTotals, isDuplicateKey } from './usage.js';
3
3
  import { createLogger } from '@drawbridge/drawbridge-telemetry';
4
4
 
5
5
  // Billing — the single source of truth for what Drawbridge charges, and the
@@ -9,7 +9,7 @@ import { createLogger } from '@drawbridge/drawbridge-telemetry';
9
9
  // billing.ai { price, cost, bill } AI requests → credit rail (debit)
10
10
  // billing.scrape { fee, bill } scrape fees → credit rail (debit)
11
11
  // billing.grant { bonus, admin } promo credits → credit rail (credit)
12
- // billing.action { record, sum } usage actions → Stripe meter rail
12
+ // billing.action { record, bulk, sum } usage actions → Stripe meter rail
13
13
  //
14
14
  // Sits on top of transactions.js (the generic credit/debit money rail) and
15
15
  // usage.js (the action-ledger + usage-totals primitives). Shopify order fees are
@@ -226,11 +226,53 @@ const scrapeFee = ({ provider, bytes, requests, url, cold }) => {
226
226
 
227
227
  };
228
228
 
229
+ // Itemized cold-fee breakdown for the scrape doc / admin dashboard: the same cents
230
+ // scrapeFee charges on a COLD scrape, plus the basis, the metered units, and the
231
+ // wholesale provider rate behind it. Reuse hits aren't represented here — the doc
232
+ // records its cold acquisition cost. `rate` is the wholesale rate in dollars per
233
+ // `rateUnit` (pre-markup, i.e. the BrightData list price); `cents` is the retail fee
234
+ // billed (incl MARKUP). Local renders have no external cost — flat, no rate.
235
+ const scrapeBreakdown = ({ provider, bytes, requests, url }) => {
236
+
237
+ if( provider === 'brightdata' ){
238
+
239
+ return {
240
+ provider,
241
+ basis : 'bytes',
242
+ units : bytes || 0,
243
+ rate : ratePerGB( url ),
244
+ rateUnit : 'GB',
245
+ cents : scrapeFee({ provider, bytes, url, cold : true })
246
+ };
247
+
248
+ }
249
+
250
+ if( provider === 'brightdata-unlocker' ){
251
+
252
+ return {
253
+ provider,
254
+ basis : 'requests',
255
+ units : requests || 0,
256
+ // cpmCents is cents-per-request; ×10 expresses it as dollars per 1,000.
257
+ rate : cpmCents( url ) * 10,
258
+ rateUnit : '1K requests',
259
+ cents : scrapeFee({ provider, requests, url, cold : true })
260
+ };
261
+
262
+ }
263
+
264
+ return { provider : provider || 'local', basis : 'local', units : null, rate : null, rateUnit : null, cents : LOCAL_FLAT_CENTS };
265
+
266
+ };
267
+
229
268
  const scrape = {
230
269
 
231
270
  // Cents for a scrape given its provider/bytes/cold basis.
232
271
  fee : scrapeFee,
233
272
 
273
+ // Itemized cold-fee breakdown (basis/units/rate/cents) for display + the scrape doc.
274
+ breakdown : scrapeBreakdown,
275
+
234
276
  // Deferred bill(scrapeId) the scrape store's resolve() hands the generation
235
277
  // flow. Atomically claims the cold (first-time) fee via the `charged` flag so
236
278
  // a BrightData scrape's metered cost is recovered ONCE; later reuses pay the
@@ -357,6 +399,76 @@ const action = {
357
399
 
358
400
  },
359
401
 
402
+ // Batch variant of record() for a caller writing many ledger rows inside ONE
403
+ // transaction. A session forbids concurrent ops, so the alternative is a serial
404
+ // await-loop costing N round-trips; this does a single bulkWrite of idempotent
405
+ // { usage, key } upserts (ordered:false), then bumps usage.totals.actions by the
406
+ // summed units of ONLY the genuinely-inserted rows (upsertedIds) — mirroring
407
+ // record()'s per-row `inserted` gate. Rows are deduped by key within the batch
408
+ // so two upserts can't target the same { usage, key }. Pass `session` to join
409
+ // the caller's transaction (the intended path).
410
+ bulk : async ({ controller, organization, usageId, rows = [], session }) => {
411
+
412
+ if( ! controller || ! organization || ! usageId || ! Array.isArray( rows ) || rows.length === 0 ) return;
413
+
414
+ const seen = new Set();
415
+ const valid = [];
416
+
417
+ for( const row of rows ){
418
+
419
+ const { type, units = 1, key, meta = {}, traceId } = row || {};
420
+ const amount = Number( units );
421
+
422
+ if( ! type || ! key || ! Number.isFinite( amount ) || amount === 0 ) continue;
423
+ if( seen.has( key ) ) continue;
424
+
425
+ seen.add( key );
426
+ valid.push({ type, amount, key, meta, traceId });
427
+
428
+ }
429
+
430
+ if( valid.length === 0 ) return;
431
+
432
+ const result = await controller.bulk({
433
+ collection : 'action',
434
+ pipeline : valid.map( ({ type, amount, key, meta, traceId }) => ({
435
+ updateOne : {
436
+ filter : { key, usage : usageId },
437
+ update : {
438
+ $setOnInsert : {
439
+ organization,
440
+ usage : usageId,
441
+ type,
442
+ units : amount,
443
+ key,
444
+ meta,
445
+ ...( traceId && { traceId })
446
+ }
447
+ },
448
+ upsert : true
449
+ }
450
+ })),
451
+ options : {
452
+ bypassDocumentValidation : true,
453
+ ordered : false,
454
+ ...( session && { session })
455
+ }
456
+ });
457
+
458
+ // upsertedIds = { <opIndex>: _id } for newly-inserted rows; sum their units.
459
+ const amount = Object.keys( result?.upsertedIds || {} ).reduce(
460
+ ( sum, index ) => sum + ( valid[ Number( index ) ]?.amount || 0 ),
461
+ 0
462
+ );
463
+
464
+ if( amount > 0 ){
465
+
466
+ await incrementUsageTotals({ controller, usageId, $inc : { 'totals.actions' : amount }, session });
467
+
468
+ }
469
+
470
+ },
471
+
360
472
  // Authoritative per-period total to bill — sum(units) over the period's ledger
361
473
  // rows. The Stripe meter emit (in the Stripe/sync layer) reports this number.
362
474
  sum : async ({ controller, usageId }) => {
package/dist/billing.js CHANGED
@@ -248,9 +248,35 @@ var scrapeFee = ({ provider, bytes, requests, url, cold }) => {
248
248
  }
249
249
  return LOCAL_FLAT_CENTS;
250
250
  };
251
+ var scrapeBreakdown = ({ provider, bytes, requests, url }) => {
252
+ if (provider === "brightdata") {
253
+ return {
254
+ provider,
255
+ basis: "bytes",
256
+ units: bytes || 0,
257
+ rate: ratePerGB(url),
258
+ rateUnit: "GB",
259
+ cents: scrapeFee({ provider, bytes, url, cold: true })
260
+ };
261
+ }
262
+ if (provider === "brightdata-unlocker") {
263
+ return {
264
+ provider,
265
+ basis: "requests",
266
+ units: requests || 0,
267
+ // cpmCents is cents-per-request; ×10 expresses it as dollars per 1,000.
268
+ rate: cpmCents(url) * 10,
269
+ rateUnit: "1K requests",
270
+ cents: scrapeFee({ provider, requests, url, cold: true })
271
+ };
272
+ }
273
+ return { provider: provider || "local", basis: "local", units: null, rate: null, rateUnit: null, cents: LOCAL_FLAT_CENTS };
274
+ };
251
275
  var scrape = {
252
276
  // Cents for a scrape given its provider/bytes/cold basis.
253
277
  fee: scrapeFee,
278
+ // Itemized cold-fee breakdown (basis/units/rate/cents) for display + the scrape doc.
279
+ breakdown: scrapeBreakdown,
254
280
  // Deferred bill(scrapeId) the scrape store's resolve() hands the generation
255
281
  // flow. Atomically claims the cold (first-time) fee via the `charged` flag so
256
282
  // a BrightData scrape's metered cost is recovered ONCE; later reuses pay the
@@ -332,6 +358,63 @@ var action = {
332
358
  await incrementUsageTotals({ controller, usageId, $inc: { "totals.actions": amount }, session });
333
359
  }
334
360
  },
361
+ // Batch variant of record() for a caller writing many ledger rows inside ONE
362
+ // transaction. A session forbids concurrent ops, so the alternative is a serial
363
+ // await-loop costing N round-trips; this does a single bulkWrite of idempotent
364
+ // { usage, key } upserts (ordered:false), then bumps usage.totals.actions by the
365
+ // summed units of ONLY the genuinely-inserted rows (upsertedIds) — mirroring
366
+ // record()'s per-row `inserted` gate. Rows are deduped by key within the batch
367
+ // so two upserts can't target the same { usage, key }. Pass `session` to join
368
+ // the caller's transaction (the intended path).
369
+ bulk: async ({ controller, organization, usageId, rows = [], session }) => {
370
+ if (!controller || !organization || !usageId || !Array.isArray(rows) || rows.length === 0) return;
371
+ const seen = /* @__PURE__ */ new Set();
372
+ const valid = [];
373
+ for (const row of rows) {
374
+ const { type, units = 1, key, meta = {}, traceId } = row || {};
375
+ const amount2 = Number(units);
376
+ if (!type || !key || !Number.isFinite(amount2) || amount2 === 0) continue;
377
+ if (seen.has(key)) continue;
378
+ seen.add(key);
379
+ valid.push({ type, amount: amount2, key, meta, traceId });
380
+ }
381
+ if (valid.length === 0) return;
382
+ const result = await controller.bulk({
383
+ collection: "action",
384
+ pipeline: valid.map(({ type, amount: amount2, key, meta, traceId }) => ({
385
+ updateOne: {
386
+ filter: { key, usage: usageId },
387
+ update: {
388
+ $setOnInsert: {
389
+ organization,
390
+ usage: usageId,
391
+ type,
392
+ units: amount2,
393
+ key,
394
+ meta,
395
+ ...traceId && { traceId }
396
+ }
397
+ },
398
+ upsert: true
399
+ }
400
+ })),
401
+ options: {
402
+ bypassDocumentValidation: true,
403
+ ordered: false,
404
+ ...session && { session }
405
+ }
406
+ });
407
+ const amount = Object.keys((result == null ? void 0 : result.upsertedIds) || {}).reduce(
408
+ (sum, index) => {
409
+ var _a;
410
+ return sum + (((_a = valid[Number(index)]) == null ? void 0 : _a.amount) || 0);
411
+ },
412
+ 0
413
+ );
414
+ if (amount > 0) {
415
+ await incrementUsageTotals({ controller, usageId, $inc: { "totals.actions": amount }, session });
416
+ }
417
+ },
335
418
  // Authoritative per-period total to bill — sum(units) over the period's ledger
336
419
  // rows. The Stripe meter emit (in the Stripe/sync layer) reports this number.
337
420
  sum: async ({ controller, usageId }) => {
package/package.json CHANGED
@@ -158,5 +158,5 @@
158
158
  "build": "tsup && npm publish"
159
159
  },
160
160
  "types": "dist/index.d.ts",
161
- "version": "0.0.70"
161
+ "version": "0.0.72"
162
162
  }