@drawbridge/drawbridge-utils 0.0.79 → 0.0.81

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/ai.cjs CHANGED
@@ -1,6 +1,8 @@
1
+ var __create = Object.create;
1
2
  var __defProp = Object.defineProperty;
2
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __getProtoOf = Object.getPrototypeOf;
4
6
  var __hasOwnProp = Object.prototype.hasOwnProperty;
5
7
  var __export = (target, all) => {
6
8
  for (var name in all)
@@ -14,6 +16,14 @@ var __copyProps = (to, from, except, desc) => {
14
16
  }
15
17
  return to;
16
18
  };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
20
+ // If the importer is in node compatibility mode or this is not an ESM
21
+ // file that has been converted to a CommonJS file using a Babel-
22
+ // compatible transform (i.e. "__esModule" has not been set), then set
23
+ // "default" to the CommonJS "module.exports" for node compatibility.
24
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
25
+ mod
26
+ ));
17
27
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
18
28
 
19
29
  // lib/ai.js
@@ -91,9 +101,6 @@ var insertTransaction = async ({
91
101
  session,
92
102
  ...rest
93
103
  }) => {
94
- var _a;
95
- const beforeRaw = (_a = user == null ? void 0 : user.balance) == null ? void 0 : _a[type];
96
- const balance = typeof beforeRaw === "number" ? { before: beforeRaw, after: beforeRaw + amount } : void 0;
97
104
  const trace = (0, import_drawbridge_telemetry.currentTraceId)();
98
105
  await db.create({
99
106
  authenticated: user,
@@ -105,7 +112,6 @@ var insertTransaction = async ({
105
112
  category,
106
113
  source,
107
114
  amount,
108
- ...balance && { balance },
109
115
  ...trace && { trace },
110
116
  ...rest,
111
117
  ...stripeInvoiceId && { stripeInvoiceId },
@@ -251,6 +257,13 @@ var ai = {
251
257
  bill: ({ db, user, model, usage, tools }) => (user == null ? void 0 : user.id) ? () => billRequest({ db, user, model, usage, tools }) : () => Promise.resolve()
252
258
  };
253
259
 
260
+ // lib/fetch.js
261
+ var import_qs = __toESM(require("qs"), 1);
262
+ var isTransientError = (error) => {
263
+ const status = error == null ? void 0 : error.status;
264
+ return !status || status === 429 || status >= 500;
265
+ };
266
+
254
267
  // lib/ai.js
255
268
  var google_client = new import_genai.GoogleGenAI({
256
269
  apiKey: process.env.GOOGLE_GEMINI_API_KEY
@@ -260,6 +273,16 @@ var googleCircuit = circuit({
260
273
  threshold: 3,
261
274
  timeout: 6e4
262
275
  });
276
+ var withRetries = async (fn, { attempts = 3, delay = 500 } = {}) => {
277
+ for (let attempt = 1; ; attempt++) {
278
+ try {
279
+ return await fn();
280
+ } catch (error) {
281
+ if (attempt >= attempts || !isTransientError(error)) throw error;
282
+ await new Promise((resolve) => setTimeout(resolve, delay * attempt));
283
+ }
284
+ }
285
+ };
263
286
  var models = {
264
287
  image: "gemini-2.5-flash-image",
265
288
  text: "gemini-3.5-flash"
@@ -293,14 +316,14 @@ var google = {
293
316
  ...refs.map((ref) => ({ inlineData: { data: ref.data, mimeType: ref.mimeType } })),
294
317
  { text: prompt.join("\n") }
295
318
  ];
296
- const response = await googleCircuit(() => google_client.models.generateContent({
319
+ const response = await googleCircuit(() => withRetries(() => google_client.models.generateContent({
297
320
  model,
298
321
  contents: { parts },
299
322
  config: {
300
323
  ...config,
301
324
  responseModalities: ["Image"]
302
325
  }
303
- }));
326
+ })));
304
327
  const content = (_f = (_e = (_d = (_c = (_b = (_a = response == null ? void 0 : response.candidates) == null ? void 0 : _a[0]) == null ? void 0 : _b.content) == null ? void 0 : _c.parts) == null ? void 0 : _d[0]) == null ? void 0 : _e.inlineData) == null ? void 0 : _f.data;
305
328
  if (!content) {
306
329
  throw new Error("Could not generate from prompt. Please try again with a different prompt.");
@@ -321,14 +344,14 @@ var google = {
321
344
  }) => {
322
345
  const model = models.text;
323
346
  const contents = parts ? { parts } : images.length ? { parts: [...images.map((image) => ({ inlineData: { data: image.data, mimeType: image.mimeType } })), { text: prompt.join("\n") }] } : prompt.join("\n");
324
- const response = await googleCircuit(() => google_client.models.generateContent({
347
+ const response = await googleCircuit(() => withRetries(() => google_client.models.generateContent({
325
348
  config: {
326
349
  ...config,
327
350
  responseModalities: ["Text"]
328
351
  },
329
352
  contents,
330
353
  model
331
- }));
354
+ })));
332
355
  const content = response == null ? void 0 : response.text;
333
356
  if (!content) {
334
357
  throw new Error("Could not generate from prompt. Please try again with a different prompt.");
package/dist/ai.d.cts CHANGED
@@ -2,9 +2,11 @@ import { GoogleGenAI } from '@google/genai';
2
2
  import { circuit } from './circuit.cjs';
3
3
  import { ai } from './billing.cjs';
4
4
  export { MARKUP } from './billing.cjs';
5
+ import { isTransientError } from './fetch.cjs';
5
6
  import './transactions.cjs';
6
7
  import '@drawbridge/drawbridge-telemetry';
7
8
  import './usage.cjs';
9
+ import 'qs';
8
10
 
9
11
  // AI request entry point. Wraps the Google GenAI client with a per-process
10
12
  // circuit breaker, owns the per-model pricing table, and atomically debits
@@ -35,6 +37,35 @@ const googleCircuit = circuit({
35
37
  timeout : 60000
36
38
  });
37
39
 
40
+ // Bounded retry for the raw model call. A single transient failure — undici
41
+ // "fetch failed" on a resource-starved pod, a 429, a 5xx — must not lose the
42
+ // generation step OR count toward the breaker: three of those trip it and
43
+ // hard-fail every AI call for the next 60s. Runs INSIDE the googleCircuit
44
+ // callback so the breaker only counts calls that failed after retries were
45
+ // exhausted, and an OPEN circuit still fails fast without retrying.
46
+ // isTransientError treats status-less errors as transient, which is correct
47
+ // here: the wrapped fn is exactly one SDK call, and the GenAI SDK reports
48
+ // network-level failures as plain Errors with no `.status`.
49
+ const withRetries = async ( fn, { attempts = 3, delay = 500 } = {} ) => {
50
+
51
+ for( let attempt = 1; ; attempt++ ){
52
+
53
+ try {
54
+
55
+ return await fn();
56
+
57
+ } catch ( error ) {
58
+
59
+ if( attempt >= attempts || ! isTransientError( error ) ) throw error;
60
+
61
+ await new Promise( ( resolve ) => setTimeout( resolve, delay * attempt ) );
62
+
63
+ }
64
+
65
+ }
66
+
67
+ };
68
+
38
69
  // gemini-2.5-flash was retired by Google (generateContent → 404 "no longer
39
70
  // available"), taking down every generation. gemini-3.5-flash is the current
40
71
  // stable flash successor. The image model is a distinct model and still live,
@@ -89,14 +120,14 @@ const google = {
89
120
  { text : prompt.join( '\n' ) }
90
121
  ];
91
122
 
92
- const response = await googleCircuit( () => google_client.models.generateContent({
123
+ const response = await googleCircuit( () => withRetries( () => google_client.models.generateContent({
93
124
  model,
94
125
  contents : { parts },
95
126
  config : {
96
127
  ...config,
97
128
  responseModalities : [ 'Image' ]
98
129
  }
99
- }) );
130
+ }) ) );
100
131
 
101
132
  const content = response?.candidates?.[ 0 ]?.content?.parts?.[ 0 ]?.inlineData?.data;
102
133
 
@@ -143,14 +174,14 @@ const google = {
143
174
  ? { parts : [ ...images.map( ( image ) => ({ inlineData : { data : image.data, mimeType : image.mimeType } }) ), { text : prompt.join( '\n' ) } ] }
144
175
  : prompt.join( '\n' );
145
176
 
146
- const response = await googleCircuit( () => google_client.models.generateContent({
177
+ const response = await googleCircuit( () => withRetries( () => google_client.models.generateContent({
147
178
  config : {
148
179
  ...config,
149
180
  responseModalities : [ 'Text' ]
150
181
  },
151
182
  contents,
152
183
  model
153
- }) );
184
+ }) ) );
154
185
 
155
186
  const content = response?.text;
156
187
 
package/dist/ai.d.ts CHANGED
@@ -2,9 +2,11 @@ import { GoogleGenAI } from '@google/genai';
2
2
  import { circuit } from './circuit.js';
3
3
  import { ai } from './billing.js';
4
4
  export { MARKUP } from './billing.js';
5
+ import { isTransientError } from './fetch.js';
5
6
  import './transactions.js';
6
7
  import '@drawbridge/drawbridge-telemetry';
7
8
  import './usage.js';
9
+ import 'qs';
8
10
 
9
11
  // AI request entry point. Wraps the Google GenAI client with a per-process
10
12
  // circuit breaker, owns the per-model pricing table, and atomically debits
@@ -35,6 +37,35 @@ const googleCircuit = circuit({
35
37
  timeout : 60000
36
38
  });
37
39
 
40
+ // Bounded retry for the raw model call. A single transient failure — undici
41
+ // "fetch failed" on a resource-starved pod, a 429, a 5xx — must not lose the
42
+ // generation step OR count toward the breaker: three of those trip it and
43
+ // hard-fail every AI call for the next 60s. Runs INSIDE the googleCircuit
44
+ // callback so the breaker only counts calls that failed after retries were
45
+ // exhausted, and an OPEN circuit still fails fast without retrying.
46
+ // isTransientError treats status-less errors as transient, which is correct
47
+ // here: the wrapped fn is exactly one SDK call, and the GenAI SDK reports
48
+ // network-level failures as plain Errors with no `.status`.
49
+ const withRetries = async ( fn, { attempts = 3, delay = 500 } = {} ) => {
50
+
51
+ for( let attempt = 1; ; attempt++ ){
52
+
53
+ try {
54
+
55
+ return await fn();
56
+
57
+ } catch ( error ) {
58
+
59
+ if( attempt >= attempts || ! isTransientError( error ) ) throw error;
60
+
61
+ await new Promise( ( resolve ) => setTimeout( resolve, delay * attempt ) );
62
+
63
+ }
64
+
65
+ }
66
+
67
+ };
68
+
38
69
  // gemini-2.5-flash was retired by Google (generateContent → 404 "no longer
39
70
  // available"), taking down every generation. gemini-3.5-flash is the current
40
71
  // stable flash successor. The image model is a distinct model and still live,
@@ -89,14 +120,14 @@ const google = {
89
120
  { text : prompt.join( '\n' ) }
90
121
  ];
91
122
 
92
- const response = await googleCircuit( () => google_client.models.generateContent({
123
+ const response = await googleCircuit( () => withRetries( () => google_client.models.generateContent({
93
124
  model,
94
125
  contents : { parts },
95
126
  config : {
96
127
  ...config,
97
128
  responseModalities : [ 'Image' ]
98
129
  }
99
- }) );
130
+ }) ) );
100
131
 
101
132
  const content = response?.candidates?.[ 0 ]?.content?.parts?.[ 0 ]?.inlineData?.data;
102
133
 
@@ -143,14 +174,14 @@ const google = {
143
174
  ? { parts : [ ...images.map( ( image ) => ({ inlineData : { data : image.data, mimeType : image.mimeType } }) ), { text : prompt.join( '\n' ) } ] }
144
175
  : prompt.join( '\n' );
145
176
 
146
- const response = await googleCircuit( () => google_client.models.generateContent({
177
+ const response = await googleCircuit( () => withRetries( () => google_client.models.generateContent({
147
178
  config : {
148
179
  ...config,
149
180
  responseModalities : [ 'Text' ]
150
181
  },
151
182
  contents,
152
183
  model
153
- }) );
184
+ }) ) );
154
185
 
155
186
  const content = response?.text;
156
187
 
package/dist/ai.js CHANGED
@@ -67,9 +67,6 @@ var insertTransaction = async ({
67
67
  session,
68
68
  ...rest
69
69
  }) => {
70
- var _a;
71
- const beforeRaw = (_a = user == null ? void 0 : user.balance) == null ? void 0 : _a[type];
72
- const balance = typeof beforeRaw === "number" ? { before: beforeRaw, after: beforeRaw + amount } : void 0;
73
70
  const trace = currentTraceId();
74
71
  await db.create({
75
72
  authenticated: user,
@@ -81,7 +78,6 @@ var insertTransaction = async ({
81
78
  category,
82
79
  source,
83
80
  amount,
84
- ...balance && { balance },
85
81
  ...trace && { trace },
86
82
  ...rest,
87
83
  ...stripeInvoiceId && { stripeInvoiceId },
@@ -227,6 +223,13 @@ var ai = {
227
223
  bill: ({ db, user, model, usage, tools }) => (user == null ? void 0 : user.id) ? () => billRequest({ db, user, model, usage, tools }) : () => Promise.resolve()
228
224
  };
229
225
 
226
+ // lib/fetch.js
227
+ import qs from "qs";
228
+ var isTransientError = (error) => {
229
+ const status = error == null ? void 0 : error.status;
230
+ return !status || status === 429 || status >= 500;
231
+ };
232
+
230
233
  // lib/ai.js
231
234
  var google_client = new GoogleGenAI({
232
235
  apiKey: process.env.GOOGLE_GEMINI_API_KEY
@@ -236,6 +239,16 @@ var googleCircuit = circuit({
236
239
  threshold: 3,
237
240
  timeout: 6e4
238
241
  });
242
+ var withRetries = async (fn, { attempts = 3, delay = 500 } = {}) => {
243
+ for (let attempt = 1; ; attempt++) {
244
+ try {
245
+ return await fn();
246
+ } catch (error) {
247
+ if (attempt >= attempts || !isTransientError(error)) throw error;
248
+ await new Promise((resolve) => setTimeout(resolve, delay * attempt));
249
+ }
250
+ }
251
+ };
239
252
  var models = {
240
253
  image: "gemini-2.5-flash-image",
241
254
  text: "gemini-3.5-flash"
@@ -269,14 +282,14 @@ var google = {
269
282
  ...refs.map((ref) => ({ inlineData: { data: ref.data, mimeType: ref.mimeType } })),
270
283
  { text: prompt.join("\n") }
271
284
  ];
272
- const response = await googleCircuit(() => google_client.models.generateContent({
285
+ const response = await googleCircuit(() => withRetries(() => google_client.models.generateContent({
273
286
  model,
274
287
  contents: { parts },
275
288
  config: {
276
289
  ...config,
277
290
  responseModalities: ["Image"]
278
291
  }
279
- }));
292
+ })));
280
293
  const content = (_f = (_e = (_d = (_c = (_b = (_a = response == null ? void 0 : response.candidates) == null ? void 0 : _a[0]) == null ? void 0 : _b.content) == null ? void 0 : _c.parts) == null ? void 0 : _d[0]) == null ? void 0 : _e.inlineData) == null ? void 0 : _f.data;
281
294
  if (!content) {
282
295
  throw new Error("Could not generate from prompt. Please try again with a different prompt.");
@@ -297,14 +310,14 @@ var google = {
297
310
  }) => {
298
311
  const model = models.text;
299
312
  const contents = parts ? { parts } : images.length ? { parts: [...images.map((image) => ({ inlineData: { data: image.data, mimeType: image.mimeType } })), { text: prompt.join("\n") }] } : prompt.join("\n");
300
- const response = await googleCircuit(() => google_client.models.generateContent({
313
+ const response = await googleCircuit(() => withRetries(() => google_client.models.generateContent({
301
314
  config: {
302
315
  ...config,
303
316
  responseModalities: ["Text"]
304
317
  },
305
318
  contents,
306
319
  model
307
- }));
320
+ })));
308
321
  const content = response == null ? void 0 : response.text;
309
322
  if (!content) {
310
323
  throw new Error("Could not generate from prompt. Please try again with a different prompt.");
package/dist/billing.cjs CHANGED
@@ -22,6 +22,7 @@ __export(billing_exports, {
22
22
  MARKUP: () => MARKUP,
23
23
  action: () => action,
24
24
  ai: () => ai,
25
+ balance: () => balance,
25
26
  grant: () => grant,
26
27
  scrape: () => scrape
27
28
  });
@@ -42,9 +43,6 @@ var insertTransaction = async ({
42
43
  session,
43
44
  ...rest
44
45
  }) => {
45
- var _a;
46
- const beforeRaw = (_a = user == null ? void 0 : user.balance) == null ? void 0 : _a[type];
47
- const balance = typeof beforeRaw === "number" ? { before: beforeRaw, after: beforeRaw + amount } : void 0;
48
46
  const trace = (0, import_drawbridge_telemetry.currentTraceId)();
49
47
  await db.create({
50
48
  authenticated: user,
@@ -56,7 +54,6 @@ var insertTransaction = async ({
56
54
  category,
57
55
  source,
58
56
  amount,
59
- ...balance && { balance },
60
57
  ...trace && { trace },
61
58
  ...rest,
62
59
  ...stripeInvoiceId && { stripeInvoiceId },
@@ -341,6 +338,28 @@ var scrape = {
341
338
  }
342
339
  }
343
340
  };
341
+ var balance = async ({ db, user, type }) => {
342
+ const userId = user && user.id || user;
343
+ if (!db || !userId || !type) return 0;
344
+ const [checkpoint] = await db.aggregate({
345
+ collection: "balanceCheckpoint",
346
+ pipeline: [
347
+ { $match: { user: userId, type } },
348
+ { $sort: { _id: -1 } },
349
+ { $limit: 1 }
350
+ ]
351
+ }).catch(() => []);
352
+ const match = { user: userId, type };
353
+ if (checkpoint == null ? void 0 : checkpoint.throughId) match._id = { $gt: checkpoint.throughId };
354
+ const [summed] = await db.aggregate({
355
+ collection: "transaction",
356
+ pipeline: [
357
+ { $match: match },
358
+ { $group: { _id: null, total: { $sum: "$amount" } } }
359
+ ]
360
+ });
361
+ return ((checkpoint == null ? void 0 : checkpoint.value) || 0) + ((summed == null ? void 0 : summed.total) || 0);
362
+ };
344
363
  var grant = {
345
364
  // System-issued credit (signup welcome, migration catch-up). Pass `session`
346
365
  // to enroll the credit in the caller's transaction.
@@ -471,6 +490,7 @@ var action = {
471
490
  MARKUP,
472
491
  action,
473
492
  ai,
493
+ balance,
474
494
  grant,
475
495
  scrape
476
496
  });
@@ -318,10 +318,58 @@ const scrape = {
318
318
 
319
319
  };
320
320
 
321
+ // ─── Balance (derived from the ledger) ──────────────────────────────────────────
322
+
323
+ // The AI credit balance is DERIVED, never stored — so it cannot drift from the
324
+ // ledger. balance = the newest checkpoint's cumulative value + Σ amount of the
325
+ // rows after it (credits +, debits −); with no checkpoint it sums the whole
326
+ // ledger. Checkpoints (`balanceCheckpoint`) are immutable rollup rows written by
327
+ // drawbridge-sync's nightly job to bound the scan on long-lived accounts — this
328
+ // reader tolerates their absence and falls back to the full sum. The
329
+ // { user:1, type:1, _id:1, amount:1 } index on `transaction` keeps both the full
330
+ // and post-checkpoint sums index-only.
331
+ //
332
+ // `user` accepts an id string or a user object (uses `.id`). `db` is any
333
+ // @drawbridge/mongodb controller (uses `.aggregate`). Returns integer cents;
334
+ // MAY be negative when a generation over-spent past zero — callers clamp the
335
+ // DISPLAY to 0, while the credit gate treats `<= 0` as "no credits".
336
+ const balance = async ({ db, user, type }) => {
337
+
338
+ const userId = ( user && user.id ) || user;
339
+
340
+ if( ! db || ! userId || ! type ) return 0;
341
+
342
+ // Newest immutable checkpoint: its `value` is the cumulative balance through
343
+ // `throughId`, so we only sum rows inserted after it. Missing collection / no
344
+ // checkpoint yet → [] and we sum the whole ledger.
345
+ const [ checkpoint ] = await db.aggregate({
346
+ collection : 'balanceCheckpoint',
347
+ pipeline : [
348
+ { $match : { user : userId, type } },
349
+ { $sort : { _id : -1 } },
350
+ { $limit : 1 }
351
+ ]
352
+ }).catch( () => [] );
353
+
354
+ const match = { user : userId, type };
355
+ if( checkpoint?.throughId ) match._id = { $gt : checkpoint.throughId };
356
+
357
+ const [ summed ] = await db.aggregate({
358
+ collection : 'transaction',
359
+ pipeline : [
360
+ { $match : match },
361
+ { $group : { _id : null, total : { $sum : '$amount' } } }
362
+ ]
363
+ });
364
+
365
+ return ( checkpoint?.value || 0 ) + ( summed?.total || 0 );
366
+
367
+ };
368
+
321
369
  // ─── Grants (credit rail) ──────────────────────────────────────────────────────
322
370
 
323
- // Promotional credits to a user's AI balance. `source` captures who acted; the
324
- // stream listener on the transaction collection does the balance.ai $inc.
371
+ // Promotional credits to a user's AI balance append-only rows the derived
372
+ // `balance` sums. `source` captures who acted.
325
373
  const grant = {
326
374
 
327
375
  // System-issued credit (signup welcome, migration catch-up). Pass `session`
@@ -497,4 +545,4 @@ const action = {
497
545
 
498
546
  };
499
547
 
500
- export { MARKUP, action, ai, grant, scrape };
548
+ export { MARKUP, action, ai, balance, grant, scrape };
package/dist/billing.d.ts CHANGED
@@ -318,10 +318,58 @@ const scrape = {
318
318
 
319
319
  };
320
320
 
321
+ // ─── Balance (derived from the ledger) ──────────────────────────────────────────
322
+
323
+ // The AI credit balance is DERIVED, never stored — so it cannot drift from the
324
+ // ledger. balance = the newest checkpoint's cumulative value + Σ amount of the
325
+ // rows after it (credits +, debits −); with no checkpoint it sums the whole
326
+ // ledger. Checkpoints (`balanceCheckpoint`) are immutable rollup rows written by
327
+ // drawbridge-sync's nightly job to bound the scan on long-lived accounts — this
328
+ // reader tolerates their absence and falls back to the full sum. The
329
+ // { user:1, type:1, _id:1, amount:1 } index on `transaction` keeps both the full
330
+ // and post-checkpoint sums index-only.
331
+ //
332
+ // `user` accepts an id string or a user object (uses `.id`). `db` is any
333
+ // @drawbridge/mongodb controller (uses `.aggregate`). Returns integer cents;
334
+ // MAY be negative when a generation over-spent past zero — callers clamp the
335
+ // DISPLAY to 0, while the credit gate treats `<= 0` as "no credits".
336
+ const balance = async ({ db, user, type }) => {
337
+
338
+ const userId = ( user && user.id ) || user;
339
+
340
+ if( ! db || ! userId || ! type ) return 0;
341
+
342
+ // Newest immutable checkpoint: its `value` is the cumulative balance through
343
+ // `throughId`, so we only sum rows inserted after it. Missing collection / no
344
+ // checkpoint yet → [] and we sum the whole ledger.
345
+ const [ checkpoint ] = await db.aggregate({
346
+ collection : 'balanceCheckpoint',
347
+ pipeline : [
348
+ { $match : { user : userId, type } },
349
+ { $sort : { _id : -1 } },
350
+ { $limit : 1 }
351
+ ]
352
+ }).catch( () => [] );
353
+
354
+ const match = { user : userId, type };
355
+ if( checkpoint?.throughId ) match._id = { $gt : checkpoint.throughId };
356
+
357
+ const [ summed ] = await db.aggregate({
358
+ collection : 'transaction',
359
+ pipeline : [
360
+ { $match : match },
361
+ { $group : { _id : null, total : { $sum : '$amount' } } }
362
+ ]
363
+ });
364
+
365
+ return ( checkpoint?.value || 0 ) + ( summed?.total || 0 );
366
+
367
+ };
368
+
321
369
  // ─── Grants (credit rail) ──────────────────────────────────────────────────────
322
370
 
323
- // Promotional credits to a user's AI balance. `source` captures who acted; the
324
- // stream listener on the transaction collection does the balance.ai $inc.
371
+ // Promotional credits to a user's AI balance append-only rows the derived
372
+ // `balance` sums. `source` captures who acted.
325
373
  const grant = {
326
374
 
327
375
  // System-issued credit (signup welcome, migration catch-up). Pass `session`
@@ -497,4 +545,4 @@ const action = {
497
545
 
498
546
  };
499
547
 
500
- export { MARKUP, action, ai, grant, scrape };
548
+ export { MARKUP, action, ai, balance, grant, scrape };
package/dist/billing.js CHANGED
@@ -13,9 +13,6 @@ var insertTransaction = async ({
13
13
  session,
14
14
  ...rest
15
15
  }) => {
16
- var _a;
17
- const beforeRaw = (_a = user == null ? void 0 : user.balance) == null ? void 0 : _a[type];
18
- const balance = typeof beforeRaw === "number" ? { before: beforeRaw, after: beforeRaw + amount } : void 0;
19
16
  const trace = currentTraceId();
20
17
  await db.create({
21
18
  authenticated: user,
@@ -27,7 +24,6 @@ var insertTransaction = async ({
27
24
  category,
28
25
  source,
29
26
  amount,
30
- ...balance && { balance },
31
27
  ...trace && { trace },
32
28
  ...rest,
33
29
  ...stripeInvoiceId && { stripeInvoiceId },
@@ -312,6 +308,28 @@ var scrape = {
312
308
  }
313
309
  }
314
310
  };
311
+ var balance = async ({ db, user, type }) => {
312
+ const userId = user && user.id || user;
313
+ if (!db || !userId || !type) return 0;
314
+ const [checkpoint] = await db.aggregate({
315
+ collection: "balanceCheckpoint",
316
+ pipeline: [
317
+ { $match: { user: userId, type } },
318
+ { $sort: { _id: -1 } },
319
+ { $limit: 1 }
320
+ ]
321
+ }).catch(() => []);
322
+ const match = { user: userId, type };
323
+ if (checkpoint == null ? void 0 : checkpoint.throughId) match._id = { $gt: checkpoint.throughId };
324
+ const [summed] = await db.aggregate({
325
+ collection: "transaction",
326
+ pipeline: [
327
+ { $match: match },
328
+ { $group: { _id: null, total: { $sum: "$amount" } } }
329
+ ]
330
+ });
331
+ return ((checkpoint == null ? void 0 : checkpoint.value) || 0) + ((summed == null ? void 0 : summed.total) || 0);
332
+ };
315
333
  var grant = {
316
334
  // System-issued credit (signup welcome, migration catch-up). Pass `session`
317
335
  // to enroll the credit in the caller's transaction.
@@ -441,6 +459,7 @@ export {
441
459
  MARKUP,
442
460
  action,
443
461
  ai,
462
+ balance,
444
463
  grant,
445
464
  scrape
446
465
  };
@@ -37,9 +37,6 @@ var insertTransaction = async ({
37
37
  session,
38
38
  ...rest
39
39
  }) => {
40
- var _a;
41
- const beforeRaw = (_a = user == null ? void 0 : user.balance) == null ? void 0 : _a[type];
42
- const balance = typeof beforeRaw === "number" ? { before: beforeRaw, after: beforeRaw + amount } : void 0;
43
40
  const trace = (0, import_drawbridge_telemetry.currentTraceId)();
44
41
  await db.create({
45
42
  authenticated: user,
@@ -51,7 +48,6 @@ var insertTransaction = async ({
51
48
  category,
52
49
  source,
53
50
  amount,
54
- ...balance && { balance },
55
51
  ...trace && { trace },
56
52
  ...rest,
57
53
  ...stripeInvoiceId && { stripeInvoiceId },
@@ -2,12 +2,13 @@ import { currentTraceId } from '@drawbridge/drawbridge-telemetry';
2
2
 
3
3
  // Ledger transaction insert helpers — no balance writes happen here.
4
4
  //
5
- // User-balance updates are the exclusive job of drawbridge-sync's
6
- // stream/transaction.js change-stream listener: every insert into the
7
- // `transaction` collection triggers an atomic $inc on user.balance.<type>
8
- // (guarded against going negative). This decouples the API surface from
9
- // the balance arithmetic and makes the `transaction` collection the single
10
- // source of truth for both audit history and balance derivation.
5
+ // The `transaction` collection is the SINGLE SOURCE OF TRUTH for balance:
6
+ // there is no stored balance field anywhere. The balance is DERIVED by summing
7
+ // a user's rows on read (see `balance` in billing.js) credits (+) and debits
8
+ // (−) so it can never drift from the ledger. credit()/debit() only append
9
+ // rows; drawbridge-sync's stream/transaction.js reacts to each insert to fire
10
+ // low/depleted notifications and emit `account.update` (it no longer maintains
11
+ // a balance).
11
12
  //
12
13
  // import { credit, debit } from '@drawbridge/drawbridge-utils/transactions';
13
14
  //
@@ -21,12 +22,9 @@ import { currentTraceId } from '@drawbridge/drawbridge-telemetry';
21
22
  // `db` is the wrapper returned by @drawbridge/mongodb — credit/debit
22
23
  // duck-type against its `.create` interface so this module has no hard dep.
23
24
  //
24
- // `user` is the user the transaction is being recorded against. The helper
25
- // reads `user.balance.<type>` to stamp `balance: { before, after }` on the
26
- // row as an audit snapshot of what the actor saw at action time — useful
27
- // for support / dispute resolution. When `balance.<type>` isn't on the user
28
- // (fresh signup, webhook-initiated row), the snapshot is omitted, which is
29
- // the honest representation of "no prior view to record".
25
+ // `user` is the user the transaction is being recorded against (stored as
26
+ // `user.id`). No per-row balance snapshot is stamped the running balance at
27
+ // any row is recoverable by summing the ledger up to it.
30
28
 
31
29
  const insertTransaction = async ({
32
30
  db,
@@ -42,10 +40,10 @@ const insertTransaction = async ({
42
40
  ...rest
43
41
  }) => {
44
42
 
45
- const beforeRaw = user?.balance?.[ type ];
46
- const balance = typeof beforeRaw === 'number'
47
- ? { before : beforeRaw, after : beforeRaw + amount }
48
- : undefined;
43
+ // No balance snapshot is stamped: the balance is DERIVED from this ledger
44
+ // (billing.balance sums it on read), so the running balance at any row is
45
+ // recoverable by summing up to that row a stored before/after would be
46
+ // redundant and, now that user.balance is gone, has no cheap source anyway.
49
47
 
50
48
  // Pull the active Sentry trace ID so every credit/debit auto-correlates
51
49
  // to the originating HTTP request or BullMQ job — useful for grouping
@@ -65,7 +63,6 @@ const insertTransaction = async ({
65
63
  category,
66
64
  source,
67
65
  amount,
68
- ...( balance && { balance }),
69
66
  ...( trace && { trace }),
70
67
  ...rest,
71
68
  ...( stripeInvoiceId && { stripeInvoiceId }),
@@ -2,12 +2,13 @@ import { currentTraceId } from '@drawbridge/drawbridge-telemetry';
2
2
 
3
3
  // Ledger transaction insert helpers — no balance writes happen here.
4
4
  //
5
- // User-balance updates are the exclusive job of drawbridge-sync's
6
- // stream/transaction.js change-stream listener: every insert into the
7
- // `transaction` collection triggers an atomic $inc on user.balance.<type>
8
- // (guarded against going negative). This decouples the API surface from
9
- // the balance arithmetic and makes the `transaction` collection the single
10
- // source of truth for both audit history and balance derivation.
5
+ // The `transaction` collection is the SINGLE SOURCE OF TRUTH for balance:
6
+ // there is no stored balance field anywhere. The balance is DERIVED by summing
7
+ // a user's rows on read (see `balance` in billing.js) credits (+) and debits
8
+ // (−) so it can never drift from the ledger. credit()/debit() only append
9
+ // rows; drawbridge-sync's stream/transaction.js reacts to each insert to fire
10
+ // low/depleted notifications and emit `account.update` (it no longer maintains
11
+ // a balance).
11
12
  //
12
13
  // import { credit, debit } from '@drawbridge/drawbridge-utils/transactions';
13
14
  //
@@ -21,12 +22,9 @@ import { currentTraceId } from '@drawbridge/drawbridge-telemetry';
21
22
  // `db` is the wrapper returned by @drawbridge/mongodb — credit/debit
22
23
  // duck-type against its `.create` interface so this module has no hard dep.
23
24
  //
24
- // `user` is the user the transaction is being recorded against. The helper
25
- // reads `user.balance.<type>` to stamp `balance: { before, after }` on the
26
- // row as an audit snapshot of what the actor saw at action time — useful
27
- // for support / dispute resolution. When `balance.<type>` isn't on the user
28
- // (fresh signup, webhook-initiated row), the snapshot is omitted, which is
29
- // the honest representation of "no prior view to record".
25
+ // `user` is the user the transaction is being recorded against (stored as
26
+ // `user.id`). No per-row balance snapshot is stamped the running balance at
27
+ // any row is recoverable by summing the ledger up to it.
30
28
 
31
29
  const insertTransaction = async ({
32
30
  db,
@@ -42,10 +40,10 @@ const insertTransaction = async ({
42
40
  ...rest
43
41
  }) => {
44
42
 
45
- const beforeRaw = user?.balance?.[ type ];
46
- const balance = typeof beforeRaw === 'number'
47
- ? { before : beforeRaw, after : beforeRaw + amount }
48
- : undefined;
43
+ // No balance snapshot is stamped: the balance is DERIVED from this ledger
44
+ // (billing.balance sums it on read), so the running balance at any row is
45
+ // recoverable by summing up to that row a stored before/after would be
46
+ // redundant and, now that user.balance is gone, has no cheap source anyway.
49
47
 
50
48
  // Pull the active Sentry trace ID so every credit/debit auto-correlates
51
49
  // to the originating HTTP request or BullMQ job — useful for grouping
@@ -65,7 +63,6 @@ const insertTransaction = async ({
65
63
  category,
66
64
  source,
67
65
  amount,
68
- ...( balance && { balance }),
69
66
  ...( trace && { trace }),
70
67
  ...rest,
71
68
  ...( stripeInvoiceId && { stripeInvoiceId }),
@@ -13,9 +13,6 @@ var insertTransaction = async ({
13
13
  session,
14
14
  ...rest
15
15
  }) => {
16
- var _a;
17
- const beforeRaw = (_a = user == null ? void 0 : user.balance) == null ? void 0 : _a[type];
18
- const balance = typeof beforeRaw === "number" ? { before: beforeRaw, after: beforeRaw + amount } : void 0;
19
16
  const trace = currentTraceId();
20
17
  await db.create({
21
18
  authenticated: user,
@@ -27,7 +24,6 @@ var insertTransaction = async ({
27
24
  category,
28
25
  source,
29
26
  amount,
30
- ...balance && { balance },
31
27
  ...trace && { trace },
32
28
  ...rest,
33
29
  ...stripeInvoiceId && { stripeInvoiceId },
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "type": "module",
3
3
  "dependencies": {
4
4
  "@drawbridge/drawbridge-agents": "0.0.10",
5
- "@drawbridge/drawbridge-telemetry": "0.0.14",
5
+ "@drawbridge/drawbridge-telemetry": "0.0.15",
6
6
  "@google/genai": "1.30.0",
7
7
  "axios": "1.16.0",
8
8
  "currency-codes": "2.2.0",
@@ -164,5 +164,5 @@
164
164
  "test": "node --test test/"
165
165
  },
166
166
  "types": "dist/index.d.ts",
167
- "version": "0.0.79"
167
+ "version": "0.0.81"
168
168
  }