@schematichq/schematic-typescript-node 1.4.8 → 1.5.0

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/README.md CHANGED
@@ -96,17 +96,17 @@ class CustomLogger implements Logger {
96
96
  // Your custom error logging logic
97
97
  console.error(`[ERROR] ${message}`, ...args);
98
98
  }
99
-
99
+
100
100
  warn(message: string, ...args: any[]): void {
101
101
  // Your custom warning logging logic
102
102
  console.warn(`[WARN] ${message}`, ...args);
103
103
  }
104
-
104
+
105
105
  info(message: string, ...args: any[]): void {
106
106
  // Your custom info logging logic
107
107
  console.info(`[INFO] ${message}`, ...args);
108
108
  }
109
-
109
+
110
110
  debug(message: string, ...args: any[]): void {
111
111
  // Your custom debug logging logic
112
112
  console.debug(`[DEBUG] ${message}`, ...args);
@@ -683,7 +683,7 @@ export default {
683
683
  ttl: 1000 * 60 * 60, // 1 hour cache TTL
684
684
  keyPrefix: 'schematic:', // Optional prefix for KV keys
685
685
  });
686
-
686
+
687
687
  // Initialize Schematic with the KV cache
688
688
  const schematic = new SchematicClient({
689
689
  apiKey: env.SCHEMATIC_API_KEY,
@@ -691,10 +691,10 @@ export default {
691
691
  flagChecks: [cache],
692
692
  }
693
693
  });
694
-
694
+
695
695
  // Your application logic...
696
696
  // ...
697
-
697
+
698
698
  // Don't forget to close the client when done
699
699
  schematic.close();
700
700
  }
@@ -798,6 +798,111 @@ const client = new SchematicClient({
798
798
  | `replicatorHealthCheck` | `number` | 30000 | Health check polling interval in milliseconds |
799
799
  | `cacheTTL` | `number` | 24 hours | Cache TTL in milliseconds |
800
800
 
801
+ ## Credit Leases and Reservations
802
+
803
+ For features metered by credit burndown (e.g. inference tokens), the SDK can enforce credit balances locally — without a Schematic API call on every check — using a lease-and-reservation system:
804
+
805
+ - A **lease** is a tranche of credits acquired transactionally from Schematic's API. It functions as a hold against the company's credit balance until it is consumed, released, or expires.
806
+ - A **reservation** is a client-side hold carved out of the lease at check time, sized to the upper bound of the operation's usage. This protects against races and over-spend between the check and the eventual usage report.
807
+ - When the work completes, a track call reports actual usage; the difference between reserved and actual usage is refunded to the lease.
808
+
809
+ > **Requirements:** Credit leases require [DataStream](#datastream) (or [Replicator Mode](#replicator-mode)) — lease-bearing checks are evaluated locally against cached flag and company state. In a horizontally-scaled deployment, a shared Redis backend is also required so that all SDK instances gate against the same lease balance; without one, lease state is per-process.
810
+
811
+ ### Setup
812
+
813
+ ```ts
814
+ import { createClient } from "redis";
815
+ import { SchematicClient } from "@schematichq/schematic-typescript-node";
816
+
817
+ const redisClient = createClient({ url: "redis://localhost:6379" });
818
+ await redisClient.connect();
819
+
820
+ const client = new SchematicClient({
821
+ apiKey: process.env.SCHEMATIC_API_KEY,
822
+ useDataStream: true,
823
+ dataStream: {
824
+ redisClient, // also backs lease + reservation state automatically
825
+ },
826
+ creditLeases: {
827
+ defaultLeaseDuration: 5 * 60 * 1000, // lease lifetime (ms)
828
+ defaultReservationTTL: 60 * 1000, // how long a reservation is held if not settled by a track (ms)
829
+ defaultLeaseSize: 10_000, // credits requested per lease
830
+ lowWaterMark: 0.25, // extend the lease in the background when its balance dips below this fraction
831
+ },
832
+ });
833
+ ```
834
+
835
+ When `dataStream.redisClient` is configured, lease and reservation state automatically lives in the same Redis, with atomic reservations driven by Lua scripts — no additional configuration needed. To point lease state at a *different* Redis, set `creditLeases.redisClient` explicitly.
836
+
837
+ ### Checking and tracking
838
+
839
+ `check()` reserves the operation's upper-bound usage against the lease and returns a reservation handle; `trackWithReservation()` settles it with actual usage:
840
+
841
+ ```ts
842
+ // Per-request: reserve up to maxTokens against the lease.
843
+ const result = await client.check(
844
+ { company: { id: "your-company-id" } },
845
+ "inference",
846
+ {
847
+ usage: maxTokens, // upper bound for this operation
848
+ eventSubtype: "inference_tokens", // the metered event
849
+ },
850
+ );
851
+ if (!result.allowed) {
852
+ throw new Error("credit balance exceeded");
853
+ }
854
+
855
+ const inference = await runInference(/* ... */);
856
+
857
+ // Report actual usage; the unused slice of the reservation is refunded to the lease.
858
+ await client.trackWithReservation(result.reservation!, inference.tokensUsed);
859
+ ```
860
+
861
+ If the caller never settles a reservation, it expires after `defaultReservationTTL` and its credits are returned to the lease. If the work outlives the reservation's TTL, `trackWithReservation` still bills the usage — the track event carries a deterministic idempotency key, so duplicate or recovery emits never double-bill. However, the local lease balance is not re-debited on that late settle (the expired reservation's hold was already swept back to the lease), so it reads high until the lease rolls over. **Set `defaultReservationTTL` above the longest expected gap between `check()` and `trackWithReservation()`** to keep the local balance accurate.
862
+
863
+ ### Pre-warming
864
+
865
+ To avoid paying the lease-acquire round trip on a session's first check, pre-warm leases when the user is identified:
866
+
867
+ ```ts
868
+ await client.identify(
869
+ {
870
+ keys: { userId: "your-user-id" },
871
+ company: { keys: { id: "your-company-id" } },
872
+ },
873
+ { prewarm: ["credit-type-id"] }, // credit type IDs to acquire leases for
874
+ );
875
+ ```
876
+
877
+ Or call `client.prewarm(evalCtx, creditTypeIds)` directly.
878
+
879
+ ### Failure behavior
880
+
881
+ By default, a check that cannot be gated (API unreachable, Redis down, lease exhausted) **fails closed** (`allowed: false`). Override per check for callers where letting traffic through is preferable to a denial:
882
+
883
+ ```ts
884
+ const result = await client.check(evalCtx, "inference", {
885
+ usage: maxTokens,
886
+ eventSubtype: "inference_tokens",
887
+ onAcquireFailure: "fail-open",
888
+ });
889
+ ```
890
+
891
+ `fail-open` does not skip evaluation: the flag's rules still run with the credit balance assumed sufficient, so plan targeting, overrides, and all non-credit conditions still apply — only the credit gate is bypassed.
892
+
893
+ ### Configuration options
894
+
895
+ | Option | Type | Default | Description |
896
+ |---|---|---|---|
897
+ | `defaultLeaseDuration` | `number` | 5 minutes | Lease lifetime in milliseconds |
898
+ | `defaultReservationTTL` | `number` | 60 seconds | How long an unsettled reservation is held (ms); set above your longest expected work duration |
899
+ | `defaultLeaseSize` | `number` | 10000 | Credits requested per lease acquire/extend |
900
+ | `lowWaterMark` | `number` | 0.25 | Extend in the background when the lease balance dips below this fraction |
901
+ | `sweepIntervalMs` | `number` | 1000 | Sweep interval for expired reservations (ms) |
902
+ | `redisClient` | `RedisClient` | `dataStream.redisClient` | Redis client for lease + reservation state |
903
+ | `redisKeyPrefix` | `string` | `dataStream.redisKeyPrefix` | Key prefix for lease + reservation keys |
904
+ | `overrides` | `object` | — | Per-credit-type overrides of the above (keyed by credit type ID) |
905
+
801
906
  ## Testing
802
907
 
803
908
  ### Offline mode
@@ -1,7 +1,10 @@
1
1
  import { CacheProvider, CacheOptions } from "./types";
2
2
  /**
3
- * Minimal interface describing the redis client methods used by RedisCacheProvider.
4
- * Compatible with the 'redis' package's RedisClientType.
3
+ * Minimal interface describing the redis client methods used by the SDK.
4
+ * Compatible with the 'redis' package's RedisClientType (node-redis v4).
5
+ *
6
+ * Includes hash + sorted-set + eval surface used by the credit-lease and
7
+ * reservation stores to coordinate state across multiple SDK pods.
5
8
  */
6
9
  export interface RedisClient {
7
10
  get(key: string): Promise<string | null>;
@@ -12,6 +15,30 @@ export interface RedisClient {
12
15
  MATCH: string;
13
16
  COUNT: number;
14
17
  }): AsyncIterable<string>;
18
+ hSet(key: string, field: string | Record<string, string | number>, value?: string | number): Promise<unknown>;
19
+ hGet(key: string, field: string): Promise<string | null | undefined>;
20
+ hGetAll(key: string): Promise<Record<string, string>>;
21
+ hDel(key: string, field: string | string[]): Promise<unknown>;
22
+ zAdd(key: string, members: {
23
+ score: number;
24
+ value: string;
25
+ } | {
26
+ score: number;
27
+ value: string;
28
+ }[]): Promise<unknown>;
29
+ zRangeByScore(key: string, min: number | string, max: number | string, options?: {
30
+ LIMIT: {
31
+ offset: number;
32
+ count: number;
33
+ };
34
+ }): Promise<string[]>;
35
+ zRem(key: string, member: string | string[]): Promise<unknown>;
36
+ zCard(key: string): Promise<number>;
37
+ eval(script: string, options: {
38
+ keys: string[];
39
+ arguments: string[];
40
+ }): Promise<unknown>;
41
+ pExpireAt(key: string, timestamp: number): Promise<unknown>;
15
42
  }
16
43
  export interface RedisOptions extends CacheOptions {
17
44
  /** Pre-created, connected redis client */
@@ -0,0 +1,49 @@
1
+ import type * as api from "../api";
2
+ import type { DataStreamClient } from "../datastream";
3
+ import type { Logger } from "../logger";
4
+ import type { CheckFlagOptions } from "../wrapper";
5
+ import { CreditLeaseManager } from "./lease-manager";
6
+ import type { ILeaseStore } from "./lease-store";
7
+ import type { IReservationStore } from "./reservation-store";
8
+ import type { CheckOptions, CheckResult } from "./types";
9
+ /** Internal helper bundling everything needed to satisfy a lease-bearing check. */
10
+ export interface CreditCheckDeps {
11
+ leaseStore: ILeaseStore;
12
+ reservations: IReservationStore;
13
+ manager: CreditLeaseManager;
14
+ datastream: DataStreamClient | undefined;
15
+ logger: Logger;
16
+ /**
17
+ * Report a `flag_check` event for a check the lease path resolved itself.
18
+ * Mirrors the plain `checkFlag` paths (which enqueue one per check) so
19
+ * lease-gated checks stay visible to flag-check analytics and company
20
+ * last-seen. Fallback exits don't call this — the plain check they
21
+ * delegate to enqueues its own.
22
+ */
23
+ enqueueFlagCheckEvent: (body: api.EventBodyFlagCheck) => void;
24
+ }
25
+ /**
26
+ * Drives a single `client.check` with `usage` set.
27
+ *
28
+ * Steps:
29
+ * 1. Resolve the matching credit-balance condition on the flag to get
30
+ * `creditId` + `consumptionRate`.
31
+ * 2. Acquire (or reuse) a lease for `(company, creditId)`.
32
+ * 3. Try to reserve `quantity × consumptionRate` from the lease.
33
+ * 4. Run the WASM rules engine against a substituted company snapshot
34
+ * (`credit_balances[creditId] = lease.localRemaining` *before* the
35
+ * reservation we just made was debited), plus `event_usage` options so
36
+ * the engine evaluates the post-call balance. The reservation we made
37
+ * in step 3 only sticks if the engine says allowed.
38
+ */
39
+ export declare function checkWithLease(deps: CreditCheckDeps, key: string, evalCtx: api.CheckFlagRequestBody, options: CheckOptions, fallback: () => Promise<CheckResult>): Promise<CheckResult>;
40
+ /**
41
+ * Build the preflight options envelope for a client-side rules evaluation.
42
+ * With an `eventSubtype` the quantity goes out as the `event_usage` pair so
43
+ * the engine matches it to the subtype's condition; without one it goes out
44
+ * as the generic `usage` knob. Exported so `client.check`'s fallback path can
45
+ * thread the same preflight through a plain flag check — any client-side
46
+ * evaluation (datastream, replicator) honors it even when the lease path
47
+ * can't run.
48
+ */
49
+ export declare function buildPreflightOptions(options: CheckOptions): CheckFlagOptions | undefined;
@@ -0,0 +1,7 @@
1
+ export { LeaseStore, leaseKey, type LeaseEntry, type ILeaseStore } from "./lease-store";
2
+ export { ReservationStore, type IReservationStore } from "./reservation-store";
3
+ export { CreditLeaseManager } from "./lease-manager";
4
+ export { RedisLeaseStore } from "./redis-lease-store";
5
+ export { RedisReservationStore } from "./redis-reservation-store";
6
+ export { DEFAULT_LEASE_DURATION_MS, DEFAULT_LEASE_SIZE, DEFAULT_LOW_WATER_MARK, DEFAULT_PREWARM_POLL_INTERVAL_MS, DEFAULT_PREWARM_RESOLVE_TIMEOUT_MS, DEFAULT_RESERVATION_TTL_MS, DEFAULT_SWEEP_INTERVAL_MS, } from "./types";
7
+ export type { CreditLeaseConfig, ResolvedLeaseConfig, Reservation, CheckOptions, CheckResult, OnAcquireFailure, TrackWithReservationOptions, } from "./types";
@@ -0,0 +1,68 @@
1
+ import type { CreditsClient } from "../api/resources/credits/client/Client";
2
+ import type { Logger } from "../logger";
3
+ import { type ILeaseStore, type LeaseEntry } from "./lease-store";
4
+ import { type CreditLeaseConfig, type ResolvedLeaseConfig } from "./types";
5
+ /**
6
+ * Owns the lifecycle of `credit_lease` rows for a single client: acquire on
7
+ * first use or after expiry, extend when the local view dips below the low
8
+ * water mark, release on `client.close()`.
9
+ *
10
+ * Concurrency: each operation (acquire, extend) has its own best-effort
11
+ * single-flight map keyed by `(company, creditType)`. It's best-effort —
12
+ * callers racing ahead of the registration can still issue duplicate wire
13
+ * calls — which is safe because the server is idempotent for an active slot
14
+ * and `replace` keeps the first live lease.
15
+ */
16
+ export declare class CreditLeaseManager {
17
+ private readonly creditsClient;
18
+ private readonly leaseStore;
19
+ private readonly logger;
20
+ private readonly config;
21
+ private readonly inflightAcquire;
22
+ private readonly inflightExtend;
23
+ constructor(opts: {
24
+ creditsClient: CreditsClient;
25
+ leaseStore: ILeaseStore;
26
+ logger: Logger;
27
+ config: CreditLeaseConfig;
28
+ });
29
+ resolveConfig(creditTypeId: string): ResolvedLeaseConfig;
30
+ /**
31
+ * Return the current lease entry, acquiring one (or replacing an expired
32
+ * one) if none is live. Best-effort single-flight: callers arriving after
33
+ * a request is registered share it (the first caller's `requestOptions`
34
+ * win); callers racing ahead of registration may duplicate the wire call,
35
+ * which the server's idempotent acquire absorbs.
36
+ * Never rejects: a store (Redis) failure is logged and reported as
37
+ * `undefined`, the same as a wire failure, so callers route it through
38
+ * their fail-open/fail-closed handling instead of an unhandled rejection.
39
+ */
40
+ acquireIfNeeded(companyId: string, creditTypeId: string, requestOptions?: CreditsClient.RequestOptions): Promise<LeaseEntry | undefined>;
41
+ private acquire;
42
+ /**
43
+ * Single-flight: kick off a background extend when one is warranted.
44
+ * An extend is triggered if EITHER:
45
+ * - the local remaining is below the low-water-mark ratio (steady-state
46
+ * refresh), or
47
+ * - the caller passes `requiredCredits` and the local remaining is below
48
+ * that figure (a single check just failed a reserve for that many
49
+ * credits — extend opportunistically instead of waiting for the next
50
+ * sub-watermark check).
51
+ * Returns the in-flight promise so callers can await it or fire-and-forget.
52
+ * Never rejects (it is often fire-and-forget — a rejection would surface
53
+ * as an unhandled promise rejection).
54
+ */
55
+ maybeExtendInBackground(companyId: string, creditTypeId: string, requiredCredits?: number, requestOptions?: CreditsClient.RequestOptions): Promise<LeaseEntry | undefined>;
56
+ private extend;
57
+ /**
58
+ * Release every live lease held in the store. ONLY safe when the store is
59
+ * per-process (in-memory): those leases are exclusively this process's, so
60
+ * releasing them on `close()` returns their unspent remainder to the
61
+ * company balance immediately instead of waiting out the lease expiry. A
62
+ * shared (Redis) store must never do this — sibling pods are still
63
+ * drawing on the same leases — and is excluded by the `list` capability
64
+ * check (only the in-memory store implements it). Best-effort: failures
65
+ * are logged and the lease falls back to server-side expiry.
66
+ */
67
+ releaseAllLocalLeases(): Promise<void>;
68
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * In-memory lease store, keyed by `${companyId}:${creditTypeId}`.
3
+ *
4
+ * Holds the lease ID, original granted amount, expiry, and the SDK's local
5
+ * view of `localRemainingCredits` — the portion of the lease not yet reserved
6
+ * by an outstanding reservation. Per-key serialization (Promise chain) keeps
7
+ * reserve / refund / replace atomic without external locking.
8
+ *
9
+ * The store doesn't talk to the API directly; `CreditLeaseManager` drives
10
+ * acquire/extend/release through the wire client and uses these methods to
11
+ * mirror remote state locally.
12
+ *
13
+ * For cross-pod deployments, swap this for `RedisLeaseStore` — both
14
+ * implement `ILeaseStore`.
15
+ */
16
+ export interface LeaseEntry {
17
+ leaseId: string;
18
+ companyId: string;
19
+ creditTypeId: string;
20
+ grantedAmount: number;
21
+ localRemainingCredits: number;
22
+ expiresAt: Date;
23
+ }
24
+ /** Backing-store contract shared by `LeaseStore` (in-memory) and `RedisLeaseStore` (shared). */
25
+ export interface ILeaseStore {
26
+ get(companyId: string, creditTypeId: string): Promise<LeaseEntry | undefined> | LeaseEntry | undefined;
27
+ /**
28
+ * Install a fresh lease for the slot, but only if no live lease already
29
+ * occupies it. Implementations must be safe against concurrent writers
30
+ * sharing the backend: if a live (unexpired) lease is already present —
31
+ * even one with a different `leaseId`, e.g. acquired by a sibling pod that
32
+ * raced this one — leave it untouched so its already-debited
33
+ * `localRemainingCredits` wins. Returns `true` if it wrote a fresh row,
34
+ * `false` if it kept an existing live lease (the caller should release the
35
+ * lease it acquired ONLY if its ID differs from the installed one — the
36
+ * server hands a racing acquire the same active lease back, and releasing
37
+ * that would pull the shared lease out from under every sibling).
38
+ */
39
+ replace(entry: Omit<LeaseEntry, "localRemainingCredits">): Promise<boolean>;
40
+ /**
41
+ * Reconcile the slot's lease to the server-authoritative `grantedAmount`
42
+ * total (after a remote extend), crediting the difference to
43
+ * `localRemainingCredits`. The delta MUST be computed atomically inside
44
+ * the store against the *current* stored total — never by the caller from
45
+ * a pre-wire-call read: with a shared backend, two pods extending the same
46
+ * lease concurrently would each apply a delta against the same stale read
47
+ * and mint phantom credits. Reconciling to the absolute total converges
48
+ * regardless of arrival order (a total a sibling already applied lands as
49
+ * a no-op). `newExpiresAt` only ever moves the expiry forward, so an
50
+ * out-of-order apply can't shorten a lease a sibling just extended. When
51
+ * `leaseId` is supplied, the extend applies ONLY if the slot still holds
52
+ * that lease: the server extended lease A, so its credits must not land on
53
+ * a successor lease B that replaced A after it expired mid-extend —
54
+ * crediting B would mint credits locally that the server granted to A
55
+ * (whose remainder it refunds at A's expiry). Mirrors the pin on `refund`.
56
+ */
57
+ extend(companyId: string, creditTypeId: string, grantedAmount: number, newExpiresAt?: Date, leaseId?: string): Promise<void>;
58
+ drop(companyId: string, creditTypeId: string): Promise<void>;
59
+ /**
60
+ * Atomically check-and-debit `credits` from the lease's remaining balance.
61
+ * Returns the post-debit `localRemainingCredits` on success, or `null` if
62
+ * there is no live lease, the balance is insufficient, or `credits` is not
63
+ * a finite non-negative number (NaN must never reach the debit — it slips
64
+ * through every numeric comparison and would poison the balance into
65
+ * allowing everything). Returning the balance rather than a boolean lets
66
+ * the caller derive the pre-debit figure (`returned + credits`) without a
67
+ * follow-up read.
68
+ */
69
+ tryReserve(companyId: string, creditTypeId: string, credits: number): Promise<number | null>;
70
+ /**
71
+ * Refund credits to the slot's lease balance (clamped at `grantedAmount`).
72
+ * When `leaseId` is supplied, the refund applies ONLY if the slot still
73
+ * holds that lease: a reservation carved out of lease A must never inflate
74
+ * a successor lease B that replaced it after A expired — A's unspent
75
+ * remainder (including this slice) was already refunded to the company
76
+ * balance server-side when A expired, so crediting B would double-count.
77
+ */
78
+ refund(companyId: string, creditTypeId: string, credits: number, leaseId?: string): Promise<void>;
79
+ /**
80
+ * Snapshot of all current entries. Only meaningful (and only implemented)
81
+ * for the per-process in-memory store, where `close()` releases this
82
+ * process's exclusively-held leases; a shared backend must NOT enumerate —
83
+ * sibling pods may still be drawing on those leases.
84
+ */
85
+ list?(): LeaseEntry[];
86
+ }
87
+ export declare function leaseKey(companyId: string, creditTypeId: string): string;
88
+ export declare class LeaseStore implements ILeaseStore {
89
+ private leases;
90
+ /**
91
+ * Per-key serializer chain. Each `withLock` appends to the tail so
92
+ * reserve / refund / replace see consistent intermediate state.
93
+ */
94
+ private locks;
95
+ /** Snapshot of the current entry, or undefined if no active lease. */
96
+ get(companyId: string, creditTypeId: string): LeaseEntry | undefined;
97
+ /**
98
+ * Replace (or insert) the lease for the given (company, creditType).
99
+ * Resets `localRemainingCredits` to `grantedAmount` for a new lease.
100
+ * If a live lease already occupies the slot — regardless of `leaseId` — it
101
+ * is left alone so any already-debited `localRemainingCredits` wins.
102
+ * Returns `true` if a fresh row was written, `false` if an existing live
103
+ * lease was kept.
104
+ */
105
+ replace(entry: Omit<LeaseEntry, "localRemainingCredits">): Promise<boolean>;
106
+ /**
107
+ * Reconcile an existing lease to the server-authoritative `grantedAmount`
108
+ * total (after a remote extend), crediting the difference to
109
+ * `localRemainingCredits`. The delta is computed against the CURRENT
110
+ * stored total under the key lock, so concurrent extends converge instead
111
+ * of double-counting (see `ILeaseStore.extend`). If no lease exists for
112
+ * the key, this is a no-op (caller should have replaced). When `leaseId`
113
+ * is given, the extend is dropped if the slot now holds a different lease.
114
+ */
115
+ extend(companyId: string, creditTypeId: string, grantedAmount: number, newExpiresAt?: Date, leaseId?: string): Promise<void>;
116
+ /** Drop the lease entry (after a remote release or lazy expiry). */
117
+ drop(companyId: string, creditTypeId: string): Promise<void>;
118
+ /**
119
+ * Attempt to reserve `credits` from the local remaining balance.
120
+ * Returns the post-debit balance on success, `null` if there isn't enough
121
+ * remaining (see `ILeaseStore.tryReserve`).
122
+ */
123
+ tryReserve(companyId: string, creditTypeId: string, credits: number): Promise<number | null>;
124
+ /**
125
+ * Refund credits back to the local balance (capped at grantedAmount).
126
+ * When `leaseId` is given, the refund is dropped if the slot now holds a
127
+ * different lease (see `ILeaseStore.refund`).
128
+ */
129
+ refund(companyId: string, creditTypeId: string, credits: number, leaseId?: string): Promise<void>;
130
+ /** Snapshot of all current entries (see `ILeaseStore.list`). */
131
+ list(): LeaseEntry[];
132
+ private withLock;
133
+ }
@@ -0,0 +1,30 @@
1
+ import type { RedisClient } from "../cache/redis";
2
+ import { type ILeaseStore, type LeaseEntry } from "./lease-store";
3
+ /**
4
+ * Redis-backed lease store. One hash per `(companyId, creditTypeId)` slot, with
5
+ * atomic mutations via single-key Lua scripts so it stays correct on both
6
+ * standalone and clustered Redis.
7
+ */
8
+ export declare class RedisLeaseStore implements ILeaseStore {
9
+ private readonly client;
10
+ private readonly keyPrefix;
11
+ private readonly defaultLeaseDurationMs;
12
+ constructor(opts: {
13
+ client: RedisClient;
14
+ keyPrefix?: string;
15
+ /**
16
+ * Defensive fallback used by `extend()` when the caller doesn't supply
17
+ * `newExpiresAt`. The lease manager always passes one today, so this
18
+ * only matters for direct callers. Default `DEFAULT_LEASE_DURATION_MS`.
19
+ */
20
+ defaultLeaseDurationMs?: number;
21
+ });
22
+ /** Public so the reservation store can target the same lease hash for refunds. */
23
+ hashKey(companyId: string, creditTypeId: string): string;
24
+ get(companyId: string, creditTypeId: string): Promise<LeaseEntry | undefined>;
25
+ replace(entry: Omit<LeaseEntry, "localRemainingCredits">): Promise<boolean>;
26
+ extend(companyId: string, creditTypeId: string, grantedAmount: number, newExpiresAt?: Date, leaseId?: string): Promise<void>;
27
+ drop(companyId: string, creditTypeId: string): Promise<void>;
28
+ tryReserve(companyId: string, creditTypeId: string, credits: number): Promise<number | null>;
29
+ refund(companyId: string, creditTypeId: string, credits: number, leaseId?: string): Promise<void>;
30
+ }
@@ -0,0 +1,46 @@
1
+ import type { RedisClient } from "../cache/redis";
2
+ import type { ILeaseStore } from "./lease-store";
3
+ import type { IReservationStore } from "./reservation-store";
4
+ import type { Reservation } from "./types";
5
+ /**
6
+ * Redis-backed reservation table. Each reservation is a hash, indexed by
7
+ * `expiresAt` in a sorted set so the sweeper can pop expired entries in
8
+ * O(log n), and by `(company, credit)` so the balance display can sum a
9
+ * tenant's open holds. All mutations use single-key operations (or single-key
10
+ * Lua), so the store is correct on standalone and clustered Redis alike — the
11
+ * unspent-slice refund is delegated to the lease store rather than reaching
12
+ * across to the lease hash inside a multi-key script.
13
+ */
14
+ export declare class RedisReservationStore implements IReservationStore {
15
+ private readonly client;
16
+ private readonly leaseStore;
17
+ private readonly sweepIntervalMs;
18
+ private readonly keyPrefix;
19
+ private sweepInterval;
20
+ private stopped;
21
+ constructor(opts: {
22
+ client: RedisClient;
23
+ leaseStore: ILeaseStore;
24
+ sweepIntervalMs?: number;
25
+ keyPrefix?: string;
26
+ });
27
+ private hashKey;
28
+ private indexKey;
29
+ private byCreditKey;
30
+ add(reservation: Reservation): Promise<void>;
31
+ get(id: string): Promise<Reservation | undefined>;
32
+ /**
33
+ * Sum open reservations for a (company, credit) with a single `HGETALL` on
34
+ * the per-tenant index hash — one round trip, one key (Cluster-safe), no
35
+ * per-reservation fan-out. The hash values are the authoritative
36
+ * `creditsReserved` figures; a field is present iff its reservation is open
37
+ * and unrefunded (consume removes the field and refunds in the same call),
38
+ * so summing them is exact. Display-path call, not a hot path.
39
+ */
40
+ reservedCredits(companyId: string, creditTypeId: string): Promise<number>;
41
+ consume(id: string, creditsConsumed: number): Promise<number | null>;
42
+ startSweep(): void;
43
+ sweepExpired(now?: Date): Promise<number>;
44
+ stop(): void;
45
+ size(): Promise<number>;
46
+ }
@@ -0,0 +1,64 @@
1
+ import type { Reservation } from "./types";
2
+ import type { ILeaseStore } from "./lease-store";
3
+ /** Backing-store contract for the reservation table. */
4
+ export interface IReservationStore {
5
+ add(reservation: Reservation): Promise<void> | void;
6
+ get(id: string): Promise<Reservation | undefined> | Reservation | undefined;
7
+ consume(id: string, creditsConsumed: number): Promise<number | null>;
8
+ /**
9
+ * Sum of `creditsReserved` across open reservations for a (company, credit).
10
+ * These credits are carved out of the lease's `localRemainingCredits` but
11
+ * aren't spent yet, so a balance display can add them back to ignore
12
+ * in-flight holds.
13
+ */
14
+ reservedCredits(companyId: string, creditTypeId: string): Promise<number> | number;
15
+ startSweep(): void;
16
+ sweepExpired(now?: Date): Promise<number>;
17
+ stop(): void;
18
+ size(): Promise<number> | number;
19
+ }
20
+ /**
21
+ * In-memory reservation table, paired with a sweep loop that returns expired
22
+ * reservations to their underlying lease.
23
+ *
24
+ * For cross-pod deployments, swap this for `RedisReservationStore` — both
25
+ * implement `IReservationStore`.
26
+ */
27
+ export declare class ReservationStore implements IReservationStore {
28
+ private readonly leaseStore;
29
+ private readonly sweepIntervalMs;
30
+ private reservations;
31
+ private sweepInterval;
32
+ private stopped;
33
+ constructor(leaseStore: ILeaseStore, sweepIntervalMs?: number);
34
+ /** Register a new reservation. Idempotent on `id`. */
35
+ add(reservation: Reservation): void;
36
+ /** Look up a reservation by ID. */
37
+ get(id: string): Reservation | undefined;
38
+ /**
39
+ * Sum open reservations for a (company, credit). Counts every reservation
40
+ * still in the table: its credits stay carved out of the lease's
41
+ * `localRemainingCredits` until `consume`/`sweepExpired` removes it and
42
+ * refunds the unspent remainder in the same step, so summing by presence
43
+ * keeps `localRemainingCredits + reservedCredits` exact.
44
+ */
45
+ reservedCredits(companyId: string, creditTypeId: string): number;
46
+ /**
47
+ * Consume a reservation — removes it from the table and refunds
48
+ * `creditsReserved - creditsConsumed` back to the underlying lease.
49
+ * Returns the credits actually consumed (clamped to `creditsReserved`)
50
+ * or `null` if the reservation is missing/already consumed.
51
+ */
52
+ consume(id: string, creditsConsumed: number): Promise<number | null>;
53
+ /** Start the background sweep loop. Safe to call repeatedly. */
54
+ startSweep(): void;
55
+ /**
56
+ * Scan for expired reservations, remove them, and refund their credits to
57
+ * the lease.
58
+ */
59
+ sweepExpired(now?: Date): Promise<number>;
60
+ /** Stop the sweep loop. */
61
+ stop(): void;
62
+ /** Test/debug: current reservation count. */
63
+ size(): number;
64
+ }
@@ -0,0 +1,44 @@
1
+ import * as api from "../api";
2
+ import type { IReservationStore } from "./reservation-store";
3
+ import type { Reservation, TrackWithReservationOptions } from "./types";
4
+ /** Outcome of consuming a reservation and building its Track event. */
5
+ export interface ReservationConsumeResult {
6
+ /** The Track event to emit. Always carries the `leaseId`. */
7
+ track: api.EventBodyTrack;
8
+ /**
9
+ * `true` when the reservation was still live and `consume` debited/refunded
10
+ * the local lease in this call. `false` when the reservation was already
11
+ * gone from the store (swept after its TTL, or already consumed): the local
12
+ * lease balance was *not* touched here, and the returned `track` is a
13
+ * recovery event so the server still bills the actual usage. The caller is
14
+ * responsible for de-duping recovery emits (see `trackWithReservation`).
15
+ */
16
+ settledLocally: boolean;
17
+ }
18
+ /**
19
+ * Build the `EventBodyTrack` payload for a Track event that consumes a
20
+ * reservation, and consume the reservation against the lease (refunding the
21
+ * unused slice locally).
22
+ *
23
+ * The Track is built from the caller-held `reservation` handle, so it can be
24
+ * emitted even when the reservation has already been swept out of the store —
25
+ * the server is the source of truth for actual consumption. When the lease is
26
+ * still live server-side the `leaseId` routes the spend through its sub-ledger;
27
+ * when the server lease has itself expired/released, the server falls through
28
+ * to a direct grant decrement. Either way the usage is billed, so a hold that
29
+ * outlives its (client-side) reservation TTL is no longer dropped on the floor.
30
+ *
31
+ * On the recovery path (`settledLocally: false`) the sweeper already refunded
32
+ * the full hold and nothing re-debits the consumed slice, so the local lease
33
+ * reads high until rollover — hence `reservationTTL` should exceed the longest
34
+ * expected work duration (see `CreditLeaseConfig.defaultReservationTTL`).
35
+ */
36
+ export declare function consumeReservationAndBuildEvent(reservations: IReservationStore, reservation: Reservation, actualQuantity: number, options?: TrackWithReservationOptions): Promise<ReservationConsumeResult>;
37
+ /**
38
+ * Build the Track event for a reservation purely from the caller-held handle —
39
+ * no store access. Split out so `trackWithReservation` can still emit the
40
+ * billing event when the local settle (`consume`) fails on an unreachable
41
+ * store: the server is the source of truth for actual consumption, so the
42
+ * usage must be reported regardless of local bookkeeping.
43
+ */
44
+ export declare function buildReservationTrackEvent(reservation: Reservation, actualQuantity: number, options?: TrackWithReservationOptions): api.EventBodyTrack;