@render-foundation/utils 0.0.249 → 0.0.251

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.
@@ -38,70 +38,109 @@ export const EUR_TO_CREDITS = BigInt(4);
38
38
  // emissions-funded ones (counting only emissions-attributed spend over-issued ~2.58M credits, 2026-08-04).
39
39
  export const GRANT_WINDOW_START = '2025-05-01';
40
40
  export const CREDIT_DECIMALS = BigInt(100000000); // 1e8
41
- /**
42
- * Pure core of the grant allotment: fold a user's OTOY grants against what's already been spent.
43
- *
44
- * consumed = attributedSpend (spend already stamped with an otoy_grant_id)
45
- * + correctionSettled (jobs whose grant is already being settled by a burn_correction)
46
- *
47
- * The correctionSettled term is the DOUBLE-SPEND GUARD: an escrow_credit correction means we're
48
- * recovering that job's dollars from escrow, so its grant is already committed. Counting it here stops
49
- * the same grant from also funding a future emissions burn.
50
- *
51
- * fifoGrantId = the oldest grant still holding room, i.e. the one the next spend draws from.
52
- */
53
- /**
54
- * Time-aware reservoir fold. Events = grant issuances (+, at issue time) and job spend (−, at job time),
55
- * processed chronologically: a job may only draw entitlement that existed WHEN IT RAN. Spend beyond the
56
- * balance at that moment is purchase-paid forever — a later grant must not retroactively absorb it
57
- * (the aggregate `granted − allSpend` formula did exactly that, under-issuing 256 users / 44,243 credits;
58
- * it also let the UI attribute jobs to grants issued days after they ran).
59
- *
60
- * Identity used everywhere (SQL + client): with net = Σamt and overshoot = max prefix deficit,
61
- * avail = max(net + overshoot, 0); beyondGrants = overshoot.
62
- */
63
- export const foldTimeAware = (events) => {
64
- // True reservoir semantics, chronological:
65
- // grant + -> capacity += amt
66
- // grant − -> capacity = max(capacity + amt, 0) (a REVERSAL claws back capacity; it is not spend
67
- // and can never create "beyond grants" — the linear min-run identity got this wrong:
68
- // a reversal ordered before its covering positives produced phantom overshoot)
69
- // job -> draw min(need, capacity); the un-drawable remainder is purchase-paid forever
41
+ // A grant is consumable for this long after issuance; whatever is left after that EXPIRES (2026-08-05
42
+ // model change). A job can only draw grants issued within the lookback window before it ran.
43
+ export const GRANT_LOOKBACK_DAYS = 60;
44
+ export const GRANT_LOOKBACK_MS = GRANT_LOOKBACK_DAYS * 24 * 3600 * 1000;
45
+ export const foldGrantWindow = (events, opts = {}) => {
46
+ const lookback = opts.lookbackMs ?? GRANT_LOOKBACK_MS;
47
+ const now = opts.now ?? Date.now();
70
48
  const sorted = [...events].sort((a, b) => a.t - b.t || (a.amt > b.amt ? -1 : 1)); // credits first on ties
71
- let cap = BigInt(0);
49
+ const buckets = [];
50
+ const jobs = [];
51
+ const remaining = (b) => b.amount - b.reversed - b.drawnEmissions - b.drawnMisrouted;
72
52
  let beyond = BigInt(0);
73
- let charged = BigInt(0);
74
53
  for (const e of sorted) {
75
54
  if (e.src === 'grant') {
76
- cap += e.amt;
77
- if (cap < BigInt(0))
78
- cap = BigInt(0);
55
+ if (e.amt > BigInt(0)) {
56
+ buckets.push({
57
+ grantId: e.grantId,
58
+ t: e.t,
59
+ amount: e.amt,
60
+ reversed: BigInt(0),
61
+ drawnEmissions: BigInt(0),
62
+ drawnMisrouted: BigInt(0),
63
+ expired: BigInt(0),
64
+ left: BigInt(0),
65
+ });
66
+ }
67
+ else {
68
+ let claw = -e.amt; // clawback hits remaining capacity oldest-first; excess is dropped
69
+ for (const b of buckets) {
70
+ if (claw <= BigInt(0))
71
+ break;
72
+ const take = remaining(b) < claw ? remaining(b) : claw;
73
+ if (take > BigInt(0)) {
74
+ b.reversed += take;
75
+ claw -= take;
76
+ }
77
+ }
78
+ }
79
79
  }
80
80
  else {
81
- const need = -e.amt; // job events are negative
82
- const take = need < cap ? need : cap;
83
- cap -= take;
84
- charged += take;
85
- beyond += need - take;
81
+ let need = -e.amt;
82
+ const total = need;
83
+ let emissionsLeft = e.emissions ?? need; // no split provided -> count it all as emissions
84
+ let jobEm = BigInt(0);
85
+ let jobMis = BigInt(0);
86
+ for (const b of buckets) {
87
+ if (need <= BigInt(0))
88
+ break;
89
+ if (b.t + lookback < e.t)
90
+ continue; // grant expired before this job ran
91
+ const take = remaining(b) < need ? remaining(b) : need;
92
+ if (take <= BigInt(0))
93
+ continue;
94
+ const em = emissionsLeft < take ? emissionsLeft : take;
95
+ b.drawnEmissions += em;
96
+ b.drawnMisrouted += take - em;
97
+ jobEm += em;
98
+ jobMis += take - em;
99
+ emissionsLeft -= em;
100
+ need -= take;
101
+ }
102
+ beyond += need;
103
+ jobs.push({
104
+ id: e.id,
105
+ t: e.t,
106
+ need: total,
107
+ drawnEmissions: jobEm,
108
+ drawnMisrouted: jobMis,
109
+ beyond: need,
110
+ });
86
111
  }
87
112
  }
88
- return { avail: cap, beyondGrants: beyond, jobCharged: charged };
89
- };
90
- export const foldGrantAllotment = (grants, attributedSpend, correctionSettled) => {
91
- const granted = grants.reduce((s, g) => s + g.amountCredits, BigInt(0));
92
- const consumed = (attributedSpend > BigInt(0) ? attributedSpend : BigInt(0)) +
93
- (correctionSettled > BigInt(0) ? correctionSettled : BigInt(0));
94
- const avail = granted > consumed ? granted - consumed : BigInt(0);
95
- let drawn = consumed;
113
+ let avail = BigInt(0);
114
+ let expired = BigInt(0);
115
+ let usedEmissions = BigInt(0);
116
+ let usedMisrouted = BigInt(0);
96
117
  let fifoGrantId;
97
- for (const g of grants) {
98
- if (drawn < g.amountCredits) {
99
- fifoGrantId = g.id;
100
- break;
118
+ for (const b of buckets) {
119
+ usedEmissions += b.drawnEmissions;
120
+ usedMisrouted += b.drawnMisrouted;
121
+ const rem = remaining(b);
122
+ if (b.t + lookback < now) {
123
+ b.expired = rem;
124
+ }
125
+ else {
126
+ b.left = rem;
127
+ avail += rem;
128
+ if (fifoGrantId === undefined && rem > BigInt(0) && b.grantId !== undefined)
129
+ fifoGrantId = b.grantId;
101
130
  }
102
- drawn -= g.amountCredits;
131
+ expired += b.expired;
103
132
  }
104
- return { granted, consumed, avail, fifoGrantId };
133
+ return {
134
+ avail,
135
+ beyondGrants: beyond,
136
+ jobCharged: usedEmissions + usedMisrouted,
137
+ usedEmissions,
138
+ usedMisrouted,
139
+ expired,
140
+ buckets,
141
+ jobs,
142
+ fifoGrantId,
143
+ };
105
144
  };
106
145
  import { begin } from './base';
107
146
  import moment from 'moment';
@@ -231,75 +270,50 @@ export const pgClient = (config) => {
231
270
  const out = {};
232
271
  if (!p.userIds.length)
233
272
  return out;
234
- const grants = await db
235
- .selectFrom(OTOY_GRANT_TABLE)
236
- .select(['id', 'user_id', 'amount_credits', 'otoy_created_at'])
237
- .where('user_id', 'in', p.userIds)
238
- .orderBy('otoy_created_at', 'asc')
239
- .orderBy('id', 'asc')
240
- .execute();
241
- if (!grants.length)
242
- return out;
243
- // EVERY grant-ledger row retires pool capacity, in either direction:
244
- // - negative delta = an emissions burn. ALL of them count, not just ones stamped with an
245
- // otoy_grant_id. Historical grants are now backfilled to cover the Sep/Oct-2025 burns, so those
246
- // burns must net against them; counting only stamped rows would re-issue ~1.06M credits of
247
- // already-spent grant.
248
- // - positive delta = the grant was CREDITED into current_user_grant_spend (the one-off canary
249
- // route). That balance reaches the split separately, so leaving it in the pool double-issues it.
250
- // Hence abs() over the whole ledger.
251
- // NOT abs() over the whole ledger — that would charge the Sep/Oct-2025 CSV *credits* as if they
252
- // were spend, double-penalising those users (464e8e33: 376,749 burned + 79,304 credited = 456,053).
253
- // A positive only retires capacity when it is tied to a pool grant (the canary route, where the
254
- // grant was moved into current_user_grant_spend and reaches the split from there).
255
- const spent = await db
256
- .selectFrom(USER_GRANT_SPEND_TABLE)
257
- .select((eb) => [
258
- 'user_id',
259
- eb.fn
260
- .sum(sql `case when render_spent_delta < 0 then -render_spent_delta
261
- when otoy_grant_id is not null then render_spent_delta
262
- else 0 end`)
263
- .as('net'),
264
- ])
265
- .where('user_id', 'in', p.userIds)
266
- .groupBy('user_id')
267
- .execute();
268
- // Fetch the raw event stream and fold in code (foldTimeAware — reservoir semantics; reversals
269
- // clamp capacity, only jobs create beyond-grants). SQL window identities can't express the clamp.
273
+ // Raw event stream, folded in code (foldGrantWindow — per-grant buckets, 60-day consumable window,
274
+ // reversals clamp capacity, only jobs create beyond-grants). SQL can't express the bucketed clamp.
270
275
  const evRes = await sql `
271
276
  select user_id::text as u, coalesce(otoy_created_at, ${GRANT_WINDOW_START}::timestamp) as t,
272
- (amount_credits * 4 * 1e8)::numeric::text as amt, 'grant' as src
277
+ (amount_credits * 4 * 1e8)::numeric::text as amt, 'grant' as src, id as gid
273
278
  from otoy_grant where user_id = any(${p.userIds}::uuid[])
274
279
  union all
275
- select user_id::text, ${GRANT_WINDOW_START}::timestamp, sum(render_spent_delta)::text, 'grant'
280
+ select user_id::text, ${GRANT_WINDOW_START}::timestamp, sum(render_spent_delta)::text, 'grant', null
276
281
  from user_grant_spend
277
282
  where user_id = any(${p.userIds}::uuid[]) and render_spent_delta > 0 and otoy_grant_id is null
278
283
  group by user_id
279
284
  union all
280
- select user_id::text, created_at, (-render_spent_delta)::text, 'grant'
285
+ select user_id::text, created_at, (-render_spent_delta)::text, 'grant', null
281
286
  from user_grant_spend
282
287
  where user_id = any(${p.userIds}::uuid[]) and render_spent_delta > 0 and otoy_grant_id is not null
283
288
  union all
284
- select user_id, completed_at, (-render_amt)::text, 'job'
289
+ select user_id, completed_at, (-render_amt)::text, 'job', null
285
290
  from job
286
291
  where user_id = any(${p.userIds}::text[]) and completed_at >= ${GRANT_WINDOW_START}::timestamp
287
292
  `.execute(db); // db is always a Kysely trx at runtime
288
293
  const byUser = new Map();
289
294
  for (const r of evRes.rows) {
290
295
  const arr = byUser.get(r.u) ?? [];
291
- arr.push({ t: new Date(r.t).getTime(), amt: BigInt(Math.round(Number(r.amt))), src: r.src });
296
+ arr.push({
297
+ t: new Date(r.t).getTime(),
298
+ amt: BigInt(Math.round(Number(r.amt))),
299
+ src: r.src,
300
+ grantId: r.gid ?? undefined,
301
+ });
292
302
  byUser.set(r.u, arr);
293
303
  }
294
304
  for (const [u, events] of byUser) {
295
- const cur = out[u] ??
296
- (out[u] = { userId: u, granted: BigInt(0), consumed: BigInt(0), avail: BigInt(0), grants: [] });
297
- const f = foldTimeAware(events);
298
- cur.granted = events.reduce((a, e) => (e.src === 'grant' && e.amt > BigInt(0) ? a + e.amt : a), BigInt(0));
299
- cur.consumed = f.jobCharged;
300
- cur.avail = f.avail;
305
+ const f = foldGrantWindow(events);
306
+ out[u] = {
307
+ userId: u,
308
+ granted: events.reduce((a, e) => (e.src === 'grant' && e.amt > BigInt(0) ? a + e.amt : a), BigInt(0)),
309
+ consumed: f.jobCharged,
310
+ avail: f.avail,
311
+ expired: f.expired,
312
+ fifoGrantId: f.fifoGrantId,
313
+ };
301
314
  if (f.beyondGrants > BigInt(0)) {
302
- log.info(`grant allotment ${u}: ${f.beyondGrants} beyond grants (purchase-paid at the time) avail ${f.avail}`);
315
+ log.info(`grant allotment ${u}: ${f.beyondGrants} beyond grants (purchase-paid at the time), ` +
316
+ `${f.expired} expired past the ${GRANT_LOOKBACK_DAYS}d window — avail ${f.avail}`);
303
317
  }
304
318
  }
305
319
  return out;
@@ -1153,16 +1167,13 @@ export const pgClient = (config) => {
1153
1167
  return;
1154
1168
  }
1155
1169
  }
1156
- // FIFO-attribute this spend to the user's oldest OTOY grant that still has room. Stamping
1157
- // otoy_grant_id is what makes getGrantAllotments count it as consumed an unattributed row
1158
- // would leave the grant looking unspent and let it fund a second burn.
1170
+ // FIFO-attribute this spend to the user's oldest LIVE OTOY grant that still has room (the fold
1171
+ // already applies the lookback window). Stamping otoy_grant_id is what shows the draw in the
1172
+ // admin drill-down — an unattributed row would leave the grant looking unspent there.
1159
1173
  let otoyGrantId = p.otoyGrantId;
1160
1174
  if (otoyGrantId === undefined && p.renderSpentDelta < BigInt(0)) {
1161
1175
  const allots = await getGrantAllotments(db.trx, { userIds: [p.userId] }, log);
1162
- const a = allots[p.userId];
1163
- if (a) {
1164
- otoyGrantId = foldGrantAllotment(a.grants, a.consumed, BigInt(0)).fifoGrantId;
1165
- }
1176
+ otoyGrantId = allots[p.userId]?.fifoGrantId;
1166
1177
  }
1167
1178
  const v = {
1168
1179
  user_id: p.userId,