token-lease
v0.1.0
Published
Reserve variable token budgets before work begins, then commit actual usage or release the lease.
Maintainers
Readme
token-lease
Reserve a variable token budget before work begins, then settle the reservation against actual usage — or release it — once the work finishes.
The problem it solves: when several concurrent tasks draw from a shared budget
(an API quota, a per-tenant token allowance, a rate ceiling), you usually do not
know the exact cost of a task until it is done. If you only subtract usage after
the fact, concurrent tasks can collectively overshoot the limit. token-lease
lets you atomically reserve an estimated amount up front so capacity is
accounted for while the work runs, and then commit the real number, release the
reservation, or let it expire when the bucket is next accessed.
The lifecycle for a single unit of work is:
reserve ──► commit (settle with actual usage)
├─► release (give the reservation back unused)
└─► expire (reservation is reclaimed on the next bucket operation)Requirements
- Node.js 22 or newer
- ES modules (the package is ESM-only; there is no CommonJS entry point)
Install
Until the first registry release, install the package directly from GitHub:
npm install github:seoulpro/token-leaseAfter an npm release is announced, npm install token-lease will install the
corresponding registry package.
The package has no runtime dependencies. The PostgreSQL store works against any
connection pool you already use; pg is not required by this package.
Quick start (in-memory)
This example runs as-is on Node.js 22+. The default store keeps all state in the current process — it is ideal for a single-process service or for tests.
import { TokenLeaseLedger } from "token-lease";
const ledger = new TokenLeaseLedger();
// 1. Create a bucket with a hard capacity.
await ledger.openBucket({ bucket: "tenant-42", limit: 100_000 });
// 2. Reserve an estimated budget before starting the work.
const { lease, bucket } = await ledger.reserve({
bucket: "tenant-42",
requestKey: "job-8ac1", // idempotency key for this reservation
fingerprint: "summarize:doc-8ac1", // caller-defined identity of the request
tokens: 4_000, // estimated usage
ttlMs: 30_000, // optional; defaults to 60_000 ms
});
console.log(lease.state); // "reserved"
console.log(bucket.availableTokens); // 96000 (100000 - 0 used - 4000 reserved)
// 3. Do the work, measure the real cost, then settle the lease.
const settled = await ledger.commit({
bucket: "tenant-42",
leaseId: lease.id,
actualTokens: 3_200,
});
console.log(settled.lease.state); // "committed"
console.log(settled.lease.actualTokens); // 3200
console.log(settled.bucket.usedTokens); // 3200
console.log(settled.bucket.reservedTokens); // 0 (the 800 unused tokens were reclaimed)If the work never runs — for example the request was rejected before any tokens were spent — hand the reservation back instead of committing:
await ledger.release({ bucket: "tenant-42", leaseId: lease.id });PostgreSQL
For state that must survive restarts or be shared across processes, use
PostgresTokenLeaseStore. It serializes operations per bucket with a
transaction-scoped advisory lock and stores each bucket as a single JSONB row.
You own the schema. This package does not create or migrate tables. Apply
the shipped schema (schema/postgres.sql) with your own migration tooling
before using the store:
-- schema/postgres.sql
CREATE TABLE token_lease_buckets (
bucket_key text PRIMARY KEY,
revision bigint NOT NULL DEFAULT 1,
state jsonb NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT token_lease_revision_range
CHECK (revision BETWEEN 1 AND 9007199254740991),
CONSTRAINT token_lease_state_object
CHECK (jsonb_typeof(state) = 'object'),
CONSTRAINT token_lease_state_bucket
CHECK (
state ? 'bucket'
AND jsonb_typeof(state -> 'bucket') = 'string'
AND state ->> 'bucket' = bucket_key
),
CONSTRAINT token_lease_state_revision
CHECK (
state ? 'revision'
AND jsonb_typeof(state -> 'revision') = 'number'
AND state ->> 'revision' ~ '^(0|[1-9][0-9]*)$'
AND (state ->> 'revision')::numeric = revision
)
);Migration tooling can locate the packaged asset without assuming a
node_modules layout:
import { postgresSchemaUrl } from "token-lease/postgres-schema";Then wire the store into the ledger:
import { Pool } from "pg";
import { TokenLeaseLedger, PostgresTokenLeaseStore } from "token-lease";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
pool.on("error", (error) => {
console.error("Unexpected PostgreSQL pool error", error);
});
const ledger = new TokenLeaseLedger({
store: new PostgresTokenLeaseStore(pool),
});
// The API is identical to the in-memory example.
await ledger.openBucket({ bucket: "tenant-42", limit: 100_000 });The store only needs an object that exposes connect(), returning a client with
query(sql, values) and release(). A pg.Pool satisfies this, but so does any
compatible wrapper. The wrapper must keep BEGIN through COMMIT or ROLLBACK
on one writable PostgreSQL session and must surface checked-out connection
failures through query rejections or EventEmitter-compatible error events.
It must also return the selected state and revision text without lossy
numeric coercion. release(error?) should discard a client when an error is
supplied. Applications using pg.Pool must also handle the pool's error event
for idle clients, as in the example above. All numeric values in serialized
PostgreSQL state, including application-added fields, must be non-negative safe
integers.
Idempotency and the fingerprint
reserve takes two caller-supplied identifiers:
requestKey— the idempotency key. Retryingreservewith the samebucketandrequestKeyreturns the existing lease instead of creating a second one, so a retried or duplicated call reserves capacity only once.fingerprint— a caller-defined string describing what the request is. Together with the reservation'stokensandttlMs, it forms the identity that a replay must match.
If the same requestKey is replayed with matching tokens, ttlMs, and
fingerprint, the original lease is returned unchanged. If any of those differ,
the call fails with code idempotency_conflict — this guards against a reused
key accidentally standing in for a different request. The fingerprint is hashed
(SHA-256) before storage; its plaintext is never persisted.
Expiration, over-reservation, and over-usage
Expiration. Every lease carries an expiresAt timestamp set to its creation
time plus ttlMs. A reserved lease expires once the clock reaches expiresAt,
at which point its reserved tokens are returned to the bucket and its state
becomes expired. Expiration is evaluated lazily: it happens whenever the
bucket is next touched by any ledger operation, and you can force it explicitly:
const { expiredLeaseIds, bucket } = await ledger.reapExpired({ bucket: "tenant-42" });There is no background timer or sweeper. A reservation for a crashed or abandoned
task is reclaimed the next time the bucket is used (or reaped), not at a precise
wall-clock instant. Committing a lease that has already expired fails with
lease_expired.
Over-reservation. Reservations are estimates, so committed usage is usually
lower than what was reserved. commit records only actualTokens as used and
returns the entire reservation, so any unused portion becomes available again
immediately (the quick start reserves 4000 and commits 3200, freeing 800).
Actual usage above the reservation. Committing more than you reserved is
allowed, as long as the bucket still has room. On commit the reservation is
released and actualTokens is charged against the limit; if that would push the
bucket's committed plus outstanding-reserved total above its limit, the commit
fails with commit_exceeds_budget and the lease stays reserved (you can retry
with a smaller number, or release it). Plan estimates with enough headroom that
real usage rarely exceeds available capacity.
API
All ledger methods are async. Bucket, lease, and request identifiers are
validated; invalid arguments reject with TokenLeaseError.
new TokenLeaseLedger(options?)
| Option | Default | Meaning |
| --- | --- | --- |
| store | new InMemoryTokenLeaseStore() | Backing store implementing mutate() and read(). |
| clock | Date.now | Returns the current time as a non-negative safe integer of milliseconds. |
| idFactory | crypto.randomUUID | Produces lease ids. |
| defaultTtlMs | 60_000 | TTL used when reserve omits ttlMs. |
| maxTtlMs | 3_600_000 | Upper bound on any lease TTL. |
| maxLeasesPerBucket | 10_000 | Maximum number of retained lease records per bucket. |
Methods
openBucket({ bucket, limit })→BucketSnapshot. Creates the bucket, or, if it already exists with the samelimit, returns it unchanged. A mismatching limit fails withbucket_limit_conflict.setLimit({ bucket, expectedLimit, limit })→BucketSnapshot. Compare-and-set the limit; fails withlimit_changedifexpectedLimitno longer matches, orlimit_below_allocatedif the new limit is below current used-plus-reserved tokens.reserve({ bucket, requestKey, fingerprint, tokens, ttlMs? })→LeaseResult. Atomically reservestokens. Fails withbudget_exceededwhen capacity is insufficient,bucket_not_foundif the bucket does not exist, orbucket_record_limitoncemaxLeasesPerBucketis reached.commit({ bucket, leaseId, actualTokens })→LeaseResult. Settles a reserved lease. Committing the same lease again with the sameactualTokensis idempotent; a different value fails withcommit_conflict.release({ bucket, leaseId })→LeaseResult. Returns a reservation unused. Releasing an already released or expired lease is idempotent; a committed lease cannot be released (lease_committed).reapExpired({ bucket })→ReapResult. Expires every overdue reservation and reports their ids.inspect({ bucket })→BucketSnapshot. Returns a current snapshot after expiring any overdue reservations.
Also exported: InMemoryTokenLeaseStore, PostgresTokenLeaseStore,
TokenLeaseError, STATE_SCHEMA_VERSION, and assertStoredBucket (validates a
stored bucket object against the current schema). The
token-lease/postgres-schema entry point exports postgresSchemaUrl:
import { readFile } from "node:fs/promises";
import { postgresSchemaUrl } from "token-lease/postgres-schema";
const schemaSql = await readFile(new URL(postgresSchemaUrl), "utf8");Custom store contract
A custom TokenLeaseStore is part of the ledger's correctness boundary.
mutate(bucket, callback) must serialize all mutations for that bucket across
every process that shares the store, invoke the callback once with the latest
isolated state, and make a changed: true state durable before resolving.
A changed state starts at revision 1 and advances the current revision by
exactly one; the built-in stores reject any other transition.
Callback, validation, serialization, or write failures must leave the previous
state observable when the store knows that commit has not occurred, and
read() must never expose a partial mutation. An indeterminate transport failure
may require an idempotent retry, as described below. The built-in stores isolate
persisted state from caller mutation; mutation results are awaited before
persistence and otherwise returned without imposing a serialization format.
Result shapes
reserve, commit, and release resolve to a LeaseResult:
{
lease: {
id, // string
state, // "reserved" | "committed" | "released" | "expired"
reservedTokens, // number
actualTokens, // number | null (set once committed)
createdAt, // number (ms)
expiresAt, // number (ms)
settledAt, // number | null (set once committed/released/expired)
},
bucket: {
bucket, // string
limit, // number
usedTokens, // number
reservedTokens, // number
availableTokens, // number = limit - usedTokens - reservedTokens
revision, // number (increments on every change)
createdAt, // number (ms)
updatedAt, // number (ms)
leases, // array of lease snapshots, oldest first
},
}openBucket, setLimit, and inspect resolve to the bucket shape above.
reapExpired resolves to { expiredLeaseIds, bucket }.
Error handling
Expected domain failures are surfaced as a thrown TokenLeaseError with a stable
code, a human-readable message, and a frozen details object. Branch on
code, not on message text:
import { TokenLeaseError } from "token-lease";
try {
await ledger.reserve({
bucket: "tenant-42",
requestKey: "job-9001",
fingerprint: "summarize:doc-9001",
tokens: 50_000,
});
} catch (error) {
if (error instanceof TokenLeaseError && error.code === "budget_exceeded") {
// error.details includes { requestedTokens, availableTokens }
// back off, queue, or retry with a smaller estimate
} else {
throw error;
}
}Codes you may want to handle include bucket_not_found, budget_exceeded,
idempotency_conflict, commit_conflict, commit_exceeds_budget,
lease_not_found, lease_expired, lease_released, lease_committed, and
invalid_input. Configuration, storage, or integrity failures can also use
invalid_clock, state_corrupt, and store_contract_violation.
Operational notes and non-goals
- Lease records are retained. Committed, released, and expired leases stay in
the bucket state; the library never deletes them. This preserves idempotent
replay but means a bucket's record set grows over time, bounded by
maxLeasesPerBucket(default 10,000), after whichreservefails withbucket_record_limit. Provision buckets, TTLs, and this bound for your request volume, or rotate bucket keys. - Clock behavior. Time comes from the injected
clock(defaultDate.now), measured in integer milliseconds. Per bucket, time never moves backwards: each operation uses the later of the clock reading and the bucket's own last-updated time. TTLs are compared against this clock; there is no monotonic timer. Keep clocks synchronized across servers: a clock that jumps ahead ratchets the bucket forward and can expire reservations early. - Shared configuration must match. Ledgers sharing PostgreSQL buckets should
use the same
defaultTtlMs,maxTtlMs, andmaxLeasesPerBucket. In particular, an omittedttlMsis resolved before it enters the idempotency fingerprint, so different defaults can turn a retry intoidempotency_conflict. - A lost commit acknowledgement is ambiguous. A connection can fail after
PostgreSQL commits but before the caller receives the result. Retrying
reserve,commit, orreleasewith the same inputs is safe. After an uncertainsetLimit, callinspect()and retry with the observed limit. - You own the PostgreSQL schema. The library reads and writes the
token_lease_bucketstable but does not create, migrate, or clean it up. Applyschema/postgres.sqland manage its lifecycle yourself. - The in-memory store is process-local. State is not shared across processes
or restarts. Use
PostgresTokenLeaseStorewhen you need durability or cross-process coordination. - No metering or billing. This library accounts for token budgets you supply. It does not measure token usage, meter consumption, price it, or produce invoices — you provide the estimates and the actual numbers.
- Estimates are yours. The ledger enforces the bucket
limitagainst the numbers you pass; the quality of your reservations determines how tightly the limit is respected under concurrency.
Security and privacy
- The
fingerprintandrequestKeyvalues you pass are hashed with SHA-256 before storage; their plaintext is not persisted. Hashing is data minimization, not encryption, so low-entropy values may still be guessable. Avoid putting raw secrets in them, and treat the stored bucket state (including in PostgreSQL) according to your own data-handling requirements. - Input identifiers are validated for type, length, and control characters, and numeric fields must be non-negative safe integers, but the library performs no authentication or authorization — enforce access control in your application.
- This project has not undergone any external security review and makes no production-readiness or fitness guarantees. Review the code and test it against your own requirements before relying on it.
License
MIT
