mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat(core): burn windows, and the rules that make an allowance mean something
The unit is one second of a reference session — baseline size, baseline card, running alone, on hardware we own. Every factor is a multiple of that, so an allowance is measured in time and a bar prints the stored number instead of converting into it. Integers throughout. Three rolling windows, one function. A counter is stored beside the time it was last written, and a counter whose timestamp falls outside its window reads as zero — so the reset is implied by the clock and nothing has to run for a window to roll clear. No scheduled job to misfire, and no race between a reset and a write landing together. The same rule on the write side is one statement rather than a read followed by a decision. The check is pure, and it is the same arithmetic the meter draws from. The complaint about usage limits is almost never the limit, it is being surprised by one, and two implementations that agree today are how a full bar and a refusal start disagreeing. Two rules on the allowances, enforced rather than remembered: - A window's allowance must exceed the window itself. Because the windows roll, one uninterrupted session asymptotes at exactly the window length, so an allowance at or below it is a wall that someone playing alone will meet. - Each longer allowance must be under what the shorter window already permits, or it can never be reached — a number that looks like a limit, reads like a promise, and never once fires. Allowances are configuration rather than constants, because they will be retuned against real burn far more often than this code changes, and a rate that needs a deploy is a rate that stays wrong until the next one. The shipped set is explicitly a placeholder: coherent enough to test against, not a pricing decision.
This commit is contained in:
161
packages/core/src/billing/limits.test.ts
Normal file
161
packages/core/src/billing/limits.test.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { afterEach, describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { Env } from '../env.js';
|
||||||
|
import { Limits } from './limits.js';
|
||||||
|
import { Window } from './window.js';
|
||||||
|
|
||||||
|
const HOUR = 60 * 60;
|
||||||
|
|
||||||
|
/** Allowances in hours, which is how anybody actually reasons about them. */
|
||||||
|
function hours(fiveHour: number, sevenDay: number, thirtyDay: number) {
|
||||||
|
return {
|
||||||
|
fiveHour: fiveHour * HOUR,
|
||||||
|
sevenDay: sevenDay * HOUR,
|
||||||
|
thirtyDay: thirtyDay * HOUR
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Env.init({});
|
||||||
|
Limits.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('The floor: one uninterrupted session must never hit a wall', () => {
|
||||||
|
test('an allowance at or below its own window is refused', () => {
|
||||||
|
// A rolling window means a single continuous session asymptotes at
|
||||||
|
// exactly the window length. So an allowance of five hours over a
|
||||||
|
// five-hour window is a wall that someone playing alone will meet, and
|
||||||
|
// meeting it is the one outcome this model may not produce.
|
||||||
|
expect(() => Limits.check(hours(5, 300, 1000), 'free')).toThrow(/must exceed the window/);
|
||||||
|
expect(() => Limits.check(hours(4, 300, 1000), 'free')).toThrow(/5-hour/);
|
||||||
|
expect(() => Limits.check(hours(10, 168, 1000), 'free')).toThrow(/7-day/);
|
||||||
|
expect(() => Limits.check(hours(10, 300, 720), 'free')).toThrow(/30-day/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('just above the window is accepted, because the rule is the floor', () => {
|
||||||
|
// Deliberately close to every bound at once: each allowance barely
|
||||||
|
// clears its own window, and each still sits under what the shorter
|
||||||
|
// window permits (170h of weekly is under 33.6 x 5.5h = 184.8h; 725h of
|
||||||
|
// monthly is under 4.29 x 170h = 728.6h). A set this tight is legal and
|
||||||
|
// miserable, which is the point — the rules bound the space, they do not
|
||||||
|
// choose within it.
|
||||||
|
expect(() => Limits.check(hours(5.5, 170, 725), 'free')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the floors are the window lengths, stated in seconds', () => {
|
||||||
|
// Spelled out so the relationship is visible rather than implied: the
|
||||||
|
// floor is not a chosen number, it is the window.
|
||||||
|
expect(Window.FIVE_HOURS).toBe(5 * HOUR);
|
||||||
|
expect(Window.SEVEN_DAYS).toBe(168 * HOUR);
|
||||||
|
expect(Window.THIRTY_DAYS).toBe(720 * HOUR);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('The nesting rule: every window has to bind', () => {
|
||||||
|
test('a longer allowance the shorter window already caps is refused', () => {
|
||||||
|
// The 5-hour window permits 10h per 5h sustained, which is 336h over a
|
||||||
|
// week. A 7-day allowance of 400h could never be reached, so it would
|
||||||
|
// read like a limit and never once fire.
|
||||||
|
expect(() => Limits.check(hours(10, 400, 1000), 'free')).toThrow(/never be reached/);
|
||||||
|
expect(() => Limits.check(hours(10, 300, 1400), 'free')).toThrow(/30-day/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the ceiling is exclusive, because equality never binds either', () => {
|
||||||
|
// 33.6 x 10h is exactly 336h; at exactly the ceiling the window fires
|
||||||
|
// only in the limit, which is the same as not firing.
|
||||||
|
expect(() => Limits.check(hours(10, 336, 1000), 'free')).toThrow(/never be reached/);
|
||||||
|
expect(() => Limits.check(hours(10, 335, 1000), 'free')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the placeholder set satisfies both rules', () => {
|
||||||
|
// It is not a pricing decision, but it has to be a coherent one, or
|
||||||
|
// nothing downstream can be tested against it.
|
||||||
|
expect(() => Limits.validate(Limits.PLACEHOLDER)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Configuration', () => {
|
||||||
|
test('unset takes the placeholder set', () => {
|
||||||
|
Env.init({});
|
||||||
|
Limits.reset();
|
||||||
|
expect(Limits.get()).toEqual(Limits.PLACEHOLDER);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the environment overrides it, and is validated on the way in', () => {
|
||||||
|
Env.init({
|
||||||
|
BURN_LIMITS: JSON.stringify({ free: hours(12, 350, 1200), paid: hours(40, 1200, 4000) })
|
||||||
|
});
|
||||||
|
Limits.reset();
|
||||||
|
expect(Limits.get().free.fiveHour).toBe(12 * HOUR);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a configured set that would not bind is refused rather than used', () => {
|
||||||
|
// The whole reason the check is code: these get retuned by whoever is
|
||||||
|
// closest to the burn data, and a set that quietly stops binding is not
|
||||||
|
// visible from the numbers.
|
||||||
|
Env.init({
|
||||||
|
BURN_LIMITS: JSON.stringify({ free: hours(10, 400, 1000), paid: hours(30, 900, 3000) })
|
||||||
|
});
|
||||||
|
Limits.reset();
|
||||||
|
expect(() => Limits.get()).toThrow(/never be reached/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed JSON is refused, not ignored', () => {
|
||||||
|
Env.init({ BURN_LIMITS: '{not json' });
|
||||||
|
Limits.reset();
|
||||||
|
expect(() => Limits.get()).toThrow(/not valid JSON/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('anything that is not the paid plan gets the free allowance', () => {
|
||||||
|
Env.init({});
|
||||||
|
Limits.reset();
|
||||||
|
for (const plan of ['free', null, undefined, 'something-we-retired']) {
|
||||||
|
expect(Limits.forPlan(plan)).toEqual(Limits.PLACEHOLDER.free);
|
||||||
|
}
|
||||||
|
expect(Limits.forPlan('paid')).toEqual(Limits.PLACEHOLDER.paid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('What the placeholder set actually means', () => {
|
||||||
|
// These are the sentences the numbers are supposed to say. If a retune
|
||||||
|
// breaks one, the retune changed the product and should say so.
|
||||||
|
const free = Limits.PLACEHOLDER.free;
|
||||||
|
|
||||||
|
test('one session running continuously never exhausts any window', () => {
|
||||||
|
for (const window of Window.ALL) {
|
||||||
|
// One session burns one unit per second, so over any window it has
|
||||||
|
// spent exactly the window length.
|
||||||
|
const state = Window.analyze({
|
||||||
|
allowance: free[window.key],
|
||||||
|
windowSeconds: window.seconds,
|
||||||
|
usage: window.seconds,
|
||||||
|
timeUpdated: new Date()
|
||||||
|
});
|
||||||
|
expect(state.exhausted).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two at once bites, and the five-hour window bites first', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const twoForFiveHours = 2 * Window.FIVE_HOURS;
|
||||||
|
expect(
|
||||||
|
Window.analyze({
|
||||||
|
allowance: free.fiveHour,
|
||||||
|
windowSeconds: Window.FIVE_HOURS,
|
||||||
|
usage: twoForFiveHours,
|
||||||
|
timeUpdated: now
|
||||||
|
}).exhausted
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// The same burn is nowhere near the weekly allowance, which is what
|
||||||
|
// makes the three windows do different jobs rather than one job thrice.
|
||||||
|
expect(
|
||||||
|
Window.analyze({
|
||||||
|
allowance: free.sevenDay,
|
||||||
|
windowSeconds: Window.SEVEN_DAYS,
|
||||||
|
usage: twoForFiveHours,
|
||||||
|
timeUpdated: now
|
||||||
|
}).exhausted
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
150
packages/core/src/billing/limits.ts
Normal file
150
packages/core/src/billing/limits.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
import { Env } from '../env.js';
|
||||||
|
import { ErrorCodes, VisibleError } from '../error.js';
|
||||||
|
import { memo } from '../utils/memo.js';
|
||||||
|
import { Window } from './window.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much burn a plan is allowed, per window.
|
||||||
|
*
|
||||||
|
* **The unit is one second of a reference session** — the baseline size, on the
|
||||||
|
* baseline card, running alone, on hardware we own. Every factor is a multiple
|
||||||
|
* of that, so an allowance is measured in *time* and a bar can print
|
||||||
|
* `6h 20m of 12h` from the stored number instead of converting into it.
|
||||||
|
* Integers throughout; burn is never a float.
|
||||||
|
*
|
||||||
|
* On a caller's own hardware the size and hardware factors are 1, because what
|
||||||
|
* they price — a share of a card we paid for — is not being spent. Burn there
|
||||||
|
* is duration multiplied by how much is running at once, and nothing about
|
||||||
|
* their GPU enters into it. Nothing is ever measured on somebody's machine.
|
||||||
|
*
|
||||||
|
* **Configuration, not constants.** These numbers are not settled and will be
|
||||||
|
* retuned against real burn far more often than this code changes. A rate that
|
||||||
|
* needs a deploy is a rate that stays wrong for a week, so they are read from
|
||||||
|
* the environment and validated at read time.
|
||||||
|
*/
|
||||||
|
export namespace Limits {
|
||||||
|
const Allowances = z.object({
|
||||||
|
fiveHour: z.number().int().positive(),
|
||||||
|
sevenDay: z.number().int().positive(),
|
||||||
|
thirtyDay: z.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Allowances = z.infer<typeof Allowances>;
|
||||||
|
|
||||||
|
export const Config = z.object({
|
||||||
|
free: Allowances,
|
||||||
|
paid: Allowances
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Config = z.infer<typeof Config>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder numbers, and deliberately labelled as such.
|
||||||
|
*
|
||||||
|
* They satisfy every rule {@link check} enforces, so the mechanism runs and
|
||||||
|
* can be tested end to end, and they are not a pricing decision. The free
|
||||||
|
* set says "one box around the clock, with room to double up now and then";
|
||||||
|
* the paid set is the same shape, larger. Both want replacing with numbers
|
||||||
|
* chosen against measured burn.
|
||||||
|
*/
|
||||||
|
export const PLACEHOLDER: Config = {
|
||||||
|
free: {
|
||||||
|
fiveHour: 10 * 60 * 60,
|
||||||
|
sevenDay: 300 * 60 * 60,
|
||||||
|
thirtyDay: 1000 * 60 * 60
|
||||||
|
},
|
||||||
|
paid: {
|
||||||
|
fiveHour: 30 * 60 * 60,
|
||||||
|
sevenDay: 900 * 60 * 60,
|
||||||
|
thirtyDay: 3000 * 60 * 60
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two rules that make a set of allowances mean anything.
|
||||||
|
*
|
||||||
|
* **A window's allowance must exceed the window itself.** Because the
|
||||||
|
* windows roll, one continuously-running session does not creep — it
|
||||||
|
* asymptotes at exactly the window length and stays there. So an allowance
|
||||||
|
* at or below its own window is one where a single uninterrupted session
|
||||||
|
* hits a wall, which is the one outcome the model may not produce: someone
|
||||||
|
* playing alone on hardware they own must never be stopped.
|
||||||
|
*
|
||||||
|
* **Each longer window must be smaller than what the shorter one already
|
||||||
|
* permits.** Sustained burn allowed by a window is `allowance ÷ window` per
|
||||||
|
* second, so a longer allowance above `allowance × (longer ÷ shorter)` can
|
||||||
|
* never be reached and is decoration — a number that looks like a limit,
|
||||||
|
* reads like a promise, and never fires.
|
||||||
|
*
|
||||||
|
* Both are cheap, and checking them here rather than in somebody's head is
|
||||||
|
* the point: these get retuned by whoever is closest to the burn data, and
|
||||||
|
* a set that quietly stops binding is not visible from the numbers.
|
||||||
|
*/
|
||||||
|
export function check(allowances: Allowances, plan: string): void {
|
||||||
|
for (const window of Window.ALL) {
|
||||||
|
const allowance = allowances[window.key];
|
||||||
|
if (allowance <= window.seconds) {
|
||||||
|
throw new VisibleError(
|
||||||
|
'internal',
|
||||||
|
ErrorCodes.Server.INTERNAL_ERROR,
|
||||||
|
`${plan}: the ${window.label} allowance (${allowance}s) must exceed the window itself (${window.seconds}s), or one uninterrupted session hits a wall`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i < Window.ALL.length; i++) {
|
||||||
|
const shorter = Window.ALL[i - 1]!;
|
||||||
|
const longer = Window.ALL[i]!;
|
||||||
|
const ceiling = (allowances[shorter.key] * longer.seconds) / shorter.seconds;
|
||||||
|
if (allowances[longer.key] >= ceiling) {
|
||||||
|
throw new VisibleError(
|
||||||
|
'internal',
|
||||||
|
ErrorCodes.Server.INTERNAL_ERROR,
|
||||||
|
`${plan}: the ${longer.label} allowance (${allowances[longer.key]}s) can never be reached, because the ${shorter.label} window already caps it at ${Math.floor(ceiling)}s — it would never bind`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validate(config: unknown): Config {
|
||||||
|
const parsed = Config.parse(config);
|
||||||
|
check(parsed.free, 'free');
|
||||||
|
check(parsed.paid, 'paid');
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _get = memo((): Config => {
|
||||||
|
const raw = Env.get().BURN_LIMITS;
|
||||||
|
if (!raw) {
|
||||||
|
return validate(PLACEHOLDER);
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
throw new VisibleError(
|
||||||
|
'internal',
|
||||||
|
ErrorCodes.Server.INTERNAL_ERROR,
|
||||||
|
'BURN_LIMITS is not valid JSON'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return validate(parsed);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function get(): Config {
|
||||||
|
return _get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the memo. Tests change the environment between cases. */
|
||||||
|
export function reset(): void {
|
||||||
|
_get.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The allowances a plan gets. Anything not `paid` is free. */
|
||||||
|
export function forPlan(plan: string | null | undefined): Allowances {
|
||||||
|
const config = get();
|
||||||
|
return plan === 'paid' ? config.paid : config.free;
|
||||||
|
}
|
||||||
|
}
|
||||||
115
packages/core/src/billing/window.test.ts
Normal file
115
packages/core/src/billing/window.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { Window } from './window.js';
|
||||||
|
|
||||||
|
const HOUR = 60 * 60;
|
||||||
|
const NOW = new Date('2026-09-18T12:00:00.000Z');
|
||||||
|
|
||||||
|
function ago(seconds: number) {
|
||||||
|
return new Date(NOW.getTime() - seconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fiveHour(usage: number, timeUpdated: Date | null, allowance = 10 * HOUR) {
|
||||||
|
return Window.analyze({
|
||||||
|
allowance,
|
||||||
|
windowSeconds: Window.FIVE_HOURS,
|
||||||
|
usage,
|
||||||
|
timeUpdated,
|
||||||
|
now: NOW
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('The staleness rule', () => {
|
||||||
|
test('a counter older than the window reads as zero', () => {
|
||||||
|
// This is what replaces a reset job. Nothing has to run for the window
|
||||||
|
// to roll clear, so there is no cron to misfire and no race between a
|
||||||
|
// reset and a write landing at the same moment.
|
||||||
|
const state = fiveHour(9 * HOUR, ago(Window.FIVE_HOURS + 1));
|
||||||
|
expect(state.used).toBe(0);
|
||||||
|
expect(state.percent).toBe(0);
|
||||||
|
expect(state.exhausted).toBe(false);
|
||||||
|
expect(state.remaining).toBe(10 * HOUR);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a counter inside the window is counted', () => {
|
||||||
|
const state = fiveHour(9 * HOUR, ago(Window.FIVE_HOURS - 60));
|
||||||
|
expect(state.used).toBe(9 * HOUR);
|
||||||
|
expect(state.exhausted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing ever recorded reads as zero rather than throwing', () => {
|
||||||
|
expect(fiveHour(0, null).used).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the boundary belongs to the window, not outside it', () => {
|
||||||
|
// Exactly one window old is the oldest moment still in view. Getting
|
||||||
|
// this backwards would silently forgive a window's worth of burn.
|
||||||
|
expect(fiveHour(9 * HOUR, ago(Window.FIVE_HOURS)).used).toBe(9 * HOUR);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Exhaustion', () => {
|
||||||
|
test('at the allowance is exhausted, not just above it', () => {
|
||||||
|
expect(fiveHour(10 * HOUR, NOW).exhausted).toBe(true);
|
||||||
|
expect(fiveHour(10 * HOUR - 1, NOW).exhausted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overrun is reported, but remaining never goes negative', () => {
|
||||||
|
// A run is never stopped mid-session, so burn past the allowance is a
|
||||||
|
// real and expected state — it just has nothing left to offer.
|
||||||
|
const state = fiveHour(14 * HOUR, NOW);
|
||||||
|
expect(state.used).toBe(14 * HOUR);
|
||||||
|
expect(state.remaining).toBe(0);
|
||||||
|
expect(state.percent).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a zero allowance is exhausted rather than dividing by zero', () => {
|
||||||
|
const state = fiveHour(0, NOW, 0);
|
||||||
|
expect(state.exhausted).toBe(true);
|
||||||
|
expect(state.percent).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('What the bar shows is what the gate reads', () => {
|
||||||
|
test('the percent the bar draws is the same number the check uses', () => {
|
||||||
|
// The complaint about usage limits is almost never the limit, it is
|
||||||
|
// being surprised by it. One arithmetic means a full bar and a refusal
|
||||||
|
// cannot disagree.
|
||||||
|
const state = fiveHour(5 * HOUR, NOW);
|
||||||
|
expect(state.percent).toBe(50);
|
||||||
|
expect(state.exhausted).toBe(false);
|
||||||
|
expect(state.remaining).toBe(5 * HOUR);
|
||||||
|
expect(state.used + state.remaining).toBe(state.allowance);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('percent floors rather than rounds, so it reads 99 until it is done', () => {
|
||||||
|
expect(fiveHour(10 * HOUR - 1, NOW).percent).toBe(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset counts from when the burn rolls out of view', () => {
|
||||||
|
const state = fiveHour(3 * HOUR, ago(HOUR));
|
||||||
|
expect(state.resetInSec).toBe(Window.FIVE_HOURS - HOUR);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cleared window has nothing to wait for', () => {
|
||||||
|
expect(fiveHour(9 * HOUR, ago(Window.FIVE_HOURS + 1)).resetInSec).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('The three windows are the same arithmetic', () => {
|
||||||
|
test('one function, parameterised by length', () => {
|
||||||
|
// Their monthly window is calendar-anchored; ours rolls. Reaching for a
|
||||||
|
// month-bounds helper here would be a subtle and expensive mistake.
|
||||||
|
for (const window of Window.ALL) {
|
||||||
|
const state = Window.analyze({
|
||||||
|
allowance: 2 * window.seconds,
|
||||||
|
windowSeconds: window.seconds,
|
||||||
|
usage: window.seconds,
|
||||||
|
timeUpdated: NOW,
|
||||||
|
now: NOW
|
||||||
|
});
|
||||||
|
expect(state.percent).toBe(50);
|
||||||
|
expect(state.exhausted).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
116
packages/core/src/billing/window.ts
Normal file
116
packages/core/src/billing/window.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
import { fn } from '../fn.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reading a rolling window, without a job that resets it.
|
||||||
|
*
|
||||||
|
* A counter is stored next to the time it was last written, and a counter
|
||||||
|
* whose timestamp falls outside the current window simply **reads as zero**.
|
||||||
|
* Nothing resets anything on a schedule: the reset is implied by the clock, so
|
||||||
|
* there is no cron to misfire and no race between a reset and a concurrent
|
||||||
|
* write. The same rule applied on the write side — increment if the stamp is
|
||||||
|
* inside the window, otherwise start again from this amount — makes the whole
|
||||||
|
* thing one statement.
|
||||||
|
*
|
||||||
|
* Everything here is pure. It takes numbers and gives an answer, which is what
|
||||||
|
* lets the meter a person sees and the check that stops them be the same
|
||||||
|
* arithmetic rather than two implementations that agree for now.
|
||||||
|
*/
|
||||||
|
export namespace Window {
|
||||||
|
/** Seconds. Named so a caller cannot pass minutes by accident. */
|
||||||
|
export const FIVE_HOURS = 5 * 60 * 60;
|
||||||
|
export const SEVEN_DAYS = 7 * 24 * 60 * 60;
|
||||||
|
export const THIRTY_DAYS = 30 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three, shortest first.
|
||||||
|
*
|
||||||
|
* Order is load-bearing: the nesting rule that keeps each allowance
|
||||||
|
* meaningful is stated between neighbours, and the bars are read top-down.
|
||||||
|
*/
|
||||||
|
export const ALL = [
|
||||||
|
{ key: 'fiveHour' as const, seconds: FIVE_HOURS, label: '5-hour' },
|
||||||
|
{ key: 'sevenDay' as const, seconds: SEVEN_DAYS, label: '7-day' },
|
||||||
|
{ key: 'thirtyDay' as const, seconds: THIRTY_DAYS, label: '30-day' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export type Key = (typeof ALL)[number]['key'];
|
||||||
|
|
||||||
|
export const State = z.object({
|
||||||
|
/** Whether a new run may start. A live one is never stopped by this. */
|
||||||
|
exhausted: z.boolean(),
|
||||||
|
/** Burn already spent in this window, after the staleness rule. */
|
||||||
|
used: z.number().int(),
|
||||||
|
/** The allowance it is spent against. Never reported without `used`. */
|
||||||
|
allowance: z.number().int(),
|
||||||
|
/** What is left, floored at zero — overrun is real but never negative. */
|
||||||
|
remaining: z.number().int(),
|
||||||
|
/**
|
||||||
|
* Whole percent used, 0–100.
|
||||||
|
*
|
||||||
|
* For the bar, and deliberately the same number the gate reads, so a
|
||||||
|
* full bar and a refusal cannot disagree.
|
||||||
|
*/
|
||||||
|
percent: z.number().int(),
|
||||||
|
/** Seconds until this window has rolled clear of the current usage. */
|
||||||
|
resetInSec: z.number().int()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type State = z.infer<typeof State>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where one window stands.
|
||||||
|
*
|
||||||
|
* `usage` and `timeUpdated` are the stored pair. A `timeUpdated` older than
|
||||||
|
* the window means everything recorded in it has rolled out of view, so the
|
||||||
|
* answer is a clean zero rather than a stale total — this is the staleness
|
||||||
|
* rule, and it is why nothing has to be reset.
|
||||||
|
*/
|
||||||
|
export const analyze = fn(
|
||||||
|
z.object({
|
||||||
|
allowance: z.number().int().nonnegative(),
|
||||||
|
windowSeconds: z.number().int().positive(),
|
||||||
|
usage: z.number().int().nonnegative(),
|
||||||
|
/** Null when nothing has ever been recorded, which reads as zero. */
|
||||||
|
timeUpdated: z.date().nullable(),
|
||||||
|
/** Injected so the arithmetic is testable without waiting. */
|
||||||
|
now: z.date().optional()
|
||||||
|
}),
|
||||||
|
(input): State => {
|
||||||
|
const now = input.now ?? new Date();
|
||||||
|
const windowMs = input.windowSeconds * 1000;
|
||||||
|
const windowStart = now.getTime() - windowMs;
|
||||||
|
|
||||||
|
// Rolled clear: the stored total describes a window that has passed.
|
||||||
|
if (!input.timeUpdated || input.timeUpdated.getTime() < windowStart) {
|
||||||
|
return {
|
||||||
|
exhausted: false,
|
||||||
|
used: 0,
|
||||||
|
allowance: input.allowance,
|
||||||
|
remaining: input.allowance,
|
||||||
|
percent: 0,
|
||||||
|
resetInSec: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const used = input.usage;
|
||||||
|
const remaining = Math.max(0, input.allowance - used);
|
||||||
|
const percent =
|
||||||
|
input.allowance === 0 ? 100 : Math.min(100, Math.floor((used / input.allowance) * 100));
|
||||||
|
|
||||||
|
// When the last write rolls out of the window, this usage is gone.
|
||||||
|
const clearsAt = input.timeUpdated.getTime() + windowMs;
|
||||||
|
const resetInSec = Math.max(0, Math.ceil((clearsAt - now.getTime()) / 1000));
|
||||||
|
|
||||||
|
return {
|
||||||
|
exhausted: used >= input.allowance,
|
||||||
|
used,
|
||||||
|
allowance: input.allowance,
|
||||||
|
remaining,
|
||||||
|
percent,
|
||||||
|
resetInSec
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,15 @@ export namespace Env {
|
|||||||
*/
|
*/
|
||||||
AUTH_INTERNAL_URL: z.string().optional(),
|
AUTH_INTERNAL_URL: z.string().optional(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Burn allowances per plan, as JSON. Unset takes the placeholder set.
|
||||||
|
*
|
||||||
|
* Configuration rather than constants because these are retuned against
|
||||||
|
* real burn far more often than the code that reads them changes, and a
|
||||||
|
* rate that needs a deploy is a rate that stays wrong until the next one.
|
||||||
|
*/
|
||||||
|
BURN_LIMITS: z.string().optional(),
|
||||||
|
|
||||||
DATABASE_URL: z.string().optional()
|
DATABASE_URL: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user