/** * @pwngh/economy-lab * * Copyright (c) Preston Neal * * This source code is licensed under the MIT license found in the * LICENSE.md file in the root directory of this source tree. * * @license MIT */ import type { AccountRef } from './accounts.js'; import type { ErrorCode, RejectionCode } from './errors.js'; import type { Amount, Currency } from './money.js'; import type { Clock, Digest, Ids, Leg, Movement, Store } from './ports.js'; /** One movement offered to the session: an idempotency key and a balanced set of CREDIT legs. */ export interface MovementRequest { idempotencyKey: string; legs: ReadonlyArray; } /** What became of one offered movement. Accepted movements turn ledger-final at settle. */ export type MovementOutcome = { status: 'accepted'; seq: number; } | { status: 'rejected'; reason: RejectionCode | ErrorCode; }; /** How the session reached the ledger: one net posting per chunk, or movement-by-movement. */ export interface SettleReport { mode: 'netted' | 'replayed'; /** Net postings committed (chunks in 'netted' mode; individual movements in 'replayed'). */ postings: number; /** Movements the replay path refused, by idempotency key (empty in 'netted' mode). */ rejected: ReadonlyArray<{ idempotencyKey: string; reason: RejectionCode | ErrorCode; }>; /** The session chain head the settlement anchored. */ journalHead: string; /** How many accepted movements the settlement covers. */ netted: number; } /** * The cross-session reservation registry: one shared per-user-account pending total, bumped at * accept time, released at settle. `add` returns the post-add total, so accept screens are * add-then-check — two concurrent debits can never both read the same headroom, in one process * or across nodes. Share one registry across every session in the process * ({@link createReservations}), or across every node ({@link sharedReservations}, backed by the * store's counter). The settle replay path remains the backstop for crash-recovery windows * either way. * * `scope` decides crash-recovery behavior: a `process` registry dies with its process, so * {@link recoverSession} re-applies the journaled reservations; a `shared` counter survives the * crash already holding them, so recovery must not re-apply — it would double-count. */ export interface Reservations { readonly scope: 'process' | 'shared'; pending(account: AccountRef): Promise | bigint; add(account: AccountRef, naturalDelta: bigint): Promise | bigint; } /** * Creates a process-local registry: a plain in-memory map, no store round-trips. Right for a * single-node deployment, shared by every session in the process. Its totals die with the * process, so {@link recoverSession} re-applies journaled reservations on recovery (see * `scope` on {@link Reservations}); a multi-node deployment uses {@link sharedReservations} * instead. */ export declare function createReservations(): Reservations; /** * A registry every node shares, backed by the store's `ReservationStore` counter — the * multi-node accept screen. Fail-closed by construction: an unreachable counter throws out of * `add`, and `record` refuses the movement rather than accepting blind. * * Crash accounting: a dead node's journaled reservations release precisely when the orphan * sweep settles its sessions (release is journal-derived). Movements accepted but not yet * flushed die with the process while their reservations remain — a leak bounded by `maxBatch` * per crashed session that only ever refuses movements (conservative), never accepts wrongly; * `reconcileReservations` (src/worker/orphans.ts) is the quiesced-maintenance repair. */ export declare function sharedReservations(store: Store): Reservations; /** * Mints epoch session ids, `sess::-`, counting epochs per scope. The nonce — * the uuid tail of a fresh id, which keeps it injectable and deterministic in tests — brands * every id this process instance mints, so a restarted process can never reuse a session id an * earlier process already settled (the settle-once collision, resurrected across restarts). */ export declare function epochMinter(ids: Ids): (scope: string) => string; /** * The ports a session runs on: the store for the journal and the settlement postings, the * digest for the session chain hash, the clock for `recordedAt` stamps. A structural subset of * Ports, so an openPorts host passes its ports straight through. */ export interface SessionPorts { store: Store; digest: Digest; clock: Clock; } /** * Session tuning. The batch and chunk defaults are sized to the engines' contention profile; * the one option correctness rides on is `reservations` — every session in the process shares * one registry, or its accept screens race each other. */ export interface SessionOptions { /** Accepted movements per journal batch; the batch commits as one insert. Default 64. */ maxBatch?: number; /** Max participant accounts per settlement chunk (the lock-width bound). Default 16. */ chunkWidth?: number; /** The shared cross-session registry; a private one still guards within this session. */ reservations?: Reservations; } /** * Opens a netting session. `record` accepts or rejects movements (idempotent per key, affordable * per the reservation registry, durable per journal batch); `flush` forces the pending batch out; * `settle` derives the net from the journal, verifies the chain, and posts it in clearing * chunks — once per session id; rotate epochs to keep a long-lived scope settling on cadence. * * `deps` is a structural subset of `Ports`, so a host composed via * `openPorts` passes its ports straight through. Share ONE `reservations` * registry across every session in the process (see {@link Reservations}). * * @example * // In the economy service, keyed by (not owned by) a world instance, epoch-rotated. A * // purchase movement carries the same fee split a main-lane sale posts: buyer debit, * // seller's net credit, REVENUE's fee credit. * const ports = await openPorts(process.env, init); * const reservations = createReservations(); // one per process * const session = openInstanceSession(ports, `sess:${worldInstanceId}:0`, { reservations }); * const price = decodeAmount('5.00', 'CREDIT'); * const fee = decodeAmount('1.50', 'CREDIT'); * await session.record({ * idempotencyKey: orderId, * legs: [ * debit(spendable(buyerId), price), * credit(earned(creatorId), subtract(price, fee)), * credit(SYSTEM.REVENUE, fee), * ], * }); * await session.settle(); // this tier's schedule: cadence, backlog, timeout, or scope close */ export declare function openInstanceSession(deps: SessionPorts, sessionId: string, options?: SessionOptions): InstanceSession; /** * Rebuilds a session from its journal — the crash-recovery path. Outcomes, the running net, and * the chain head all re-derive from the journal rows; a rebuilt session can keep recording (if * it never settled) or go straight to settle, and settle keying each chunk on its posting's * existence makes a half-settled session finish rather than double-post. Recovery probes for a * prior settlement posting: a session that already settled refuses further movements the same * way a live one does, so recovery can finish a settle but never reopen a settled epoch. */ export declare function recoverSession(deps: SessionPorts, sessionId: string, options?: SessionOptions): Promise; /** * Refunds one prefund escrow's remainder to its owner's spendable balance, by the deterministic * `esc_refund_` txn id every repair path shares: the lane's own close, the orphan sweep, and the * retention sweep post this identical entry, so whichever runs first wins and the rest no-op on * the existing posting. Returns the refunded minor amount, or null when the escrow is empty or * already refunded. */ export declare function refundEscrowRemainder(store: Store, sessionId: string, userId: string): Promise; /** * A durable journal session: `record` accepts movements (idempotent per key, screened against * the reservation registry), `flush` commits the pending batch, `settle` re-derives the net * from the journal, re-verifies the hash chain, and posts it in clearing chunks — once per * session id, epochs rotate after that. Construct through {@link openInstanceSession}, or * {@link recoverSession} after a crash. A session is a single-writer object: interleaved * concurrent `record` calls can fork the chain on one seq, so a concurrent edge serializes on * top (as {@link InstanceEconomy} does). */ export declare class InstanceSession { private readonly deps; private readonly sessionId; private readonly maxBatch; private readonly chunkWidth; private readonly reservations; private readonly outcomes; private settled; private replayForced; private readonly accepted; private pending; private readonly opening; private head; private seq; constructor(deps: SessionPorts, sessionId: string, options?: SessionOptions); /** * Accepts or rejects one movement. Acceptance means: affordable against (first-touch balance + * everything pending across sessions sharing the registry) and queued for the next journal * batch. Unbalanced or non-CREDIT legs throw. A repeat of a seen key replays its recorded * outcome. */ record(request: MovementRequest): Promise; /** Commits the pending batch to the journal — one insert, one fsync for the whole batch. */ flush(): Promise; /** * Whether this session's settle already ran — on a recovered session, what the stored * settlement evidence proved. The orphan sweep (src/worker/orphans.ts) reads this to tell a * finished epoch from one that still needs settling. */ wasSettled(): boolean; /** * Settles the whole session: flush, re-derive the net from the JOURNAL (never from memory), * re-verify the session chain, then post the net in clearing chunks. On a refused chunk, * compensate what posted and replay movement-by-movement, so every accepted movement ends in * exactly one ledger-final outcome either way. */ settle(): Promise; private reserve; private unwind; private openingBalance; private released; private releaseReservations; private remember; private chain; private journal; private postChunk; private replay; /** Internal recovery hook for {@link recoverSession}; not part of the public surface. */ __recover(): Promise; private chunkCount; } export type { Movement, Leg, Amount, Currency }; export { scopeRouter } from './router.js'; export { openClusterNode } from './cluster.js'; export type { ClusterNode, ClusterNodeDeps, ClusterNodeOptions, } from './cluster.js'; export { sweepOrphanSessions, reconcileReservations, } from './worker/orphans.js'; export type { OrphanSweepCtx, OrphanSweepInput, OrphanSweepSummary, } from './worker/orphans.js'; export { openInstanceEconomy, openInstanceEconomies } from './instance.js'; export type { InstanceEconomies, InstanceEconomiesOptions, InstanceEconomy, InstanceEconomyDeps, InstanceEconomyOptions, InstancePurchase, InstanceSettleReport, InstanceSweepReport, ProductKind, PurchaseOutcome, } from './instance.js';