@bymax-one/nest-ai-tokens 1.0.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.
@@ -0,0 +1,198 @@
1
+ -- @bymax-one/nest-ai-tokens — initial schema (spec §15.3).
2
+ -- Seven tables; all money is BIGINT nano-USD; every table carries "tenantId".
3
+ -- Apply with `prisma db execute --file` / `psql`, or let `prisma migrate` generate
4
+ -- the equivalent from `schema.prisma.fragment`. The two PARTIAL indexes at the end
5
+ -- cannot be expressed in the Prisma schema and are the reason for the shipped SQL.
6
+
7
+ -- ---------------------------------------------------------------------------
8
+ -- ai_usage_records — the immutable usage ledger (append-only).
9
+ -- ---------------------------------------------------------------------------
10
+ CREATE TABLE "ai_usage_records" (
11
+ "id" TEXT NOT NULL,
12
+ "tenantId" TEXT NOT NULL,
13
+ "scopeType" TEXT NOT NULL,
14
+ "scopeId" TEXT NOT NULL,
15
+ "beneficiaryType" TEXT,
16
+ "beneficiaryId" TEXT,
17
+ "requestedBy" TEXT,
18
+ "provider" TEXT NOT NULL,
19
+ "model" TEXT NOT NULL,
20
+ "requestedModel" TEXT,
21
+ "operation" TEXT NOT NULL,
22
+ "serviceTier" TEXT NOT NULL DEFAULT 'standard',
23
+ "feature" TEXT NOT NULL,
24
+ "tags" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
25
+ "inputTokens" INTEGER NOT NULL DEFAULT 0,
26
+ "outputTokens" INTEGER NOT NULL DEFAULT 0,
27
+ "cacheReadTokens" INTEGER NOT NULL DEFAULT 0,
28
+ "cacheWrite5mTokens" INTEGER NOT NULL DEFAULT 0,
29
+ "cacheWrite1hTokens" INTEGER NOT NULL DEFAULT 0,
30
+ "reasoningTokens" INTEGER NOT NULL DEFAULT 0,
31
+ "audioInTokens" INTEGER NOT NULL DEFAULT 0,
32
+ "audioOutTokens" INTEGER NOT NULL DEFAULT 0,
33
+ "imageInTokens" INTEGER NOT NULL DEFAULT 0,
34
+ "imageOutTokens" INTEGER NOT NULL DEFAULT 0,
35
+ "totalTokens" INTEGER NOT NULL,
36
+ "extraUnits" JSONB,
37
+ "priceVersionId" TEXT,
38
+ "rawCostNanoUsd" BIGINT NOT NULL,
39
+ "surchargeNanoUsd" BIGINT NOT NULL DEFAULT 0,
40
+ "billedCostNanoUsd" BIGINT NOT NULL,
41
+ "markupMultiplier" DECIMAL(10,4) NOT NULL,
42
+ "currency" TEXT NOT NULL DEFAULT 'USD',
43
+ "priceMissing" BOOLEAN NOT NULL DEFAULT false,
44
+ "status" TEXT NOT NULL DEFAULT 'posted',
45
+ "reversedByRecordId" TEXT,
46
+ "reversesRecordId" TEXT,
47
+ "idempotencyKey" TEXT NOT NULL,
48
+ "payloadHash" TEXT NOT NULL,
49
+ "correlationId" TEXT,
50
+ "requestId" TEXT,
51
+ "isSystemCost" BOOLEAN NOT NULL DEFAULT false,
52
+ "systemCostCategory" TEXT,
53
+ "enforced" BOOLEAN NOT NULL DEFAULT false,
54
+ "prevHash" TEXT,
55
+ "hash" TEXT,
56
+ "occurredAt" TIMESTAMP(3) NOT NULL,
57
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
58
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
59
+ CONSTRAINT "ai_usage_records_pkey" PRIMARY KEY ("id")
60
+ );
61
+ CREATE UNIQUE INDEX "ai_usage_records_tenantId_idempotencyKey_key" ON "ai_usage_records" ("tenantId", "idempotencyKey");
62
+ CREATE INDEX "ai_usage_records_tenantId_occurredAt_idx" ON "ai_usage_records" ("tenantId", "occurredAt");
63
+ CREATE INDEX "ai_usage_records_tenantId_scopeType_scopeId_occurredAt_idx" ON "ai_usage_records" ("tenantId", "scopeType", "scopeId", "occurredAt");
64
+ CREATE INDEX "ai_usage_records_tenantId_feature_occurredAt_idx" ON "ai_usage_records" ("tenantId", "feature", "occurredAt");
65
+ CREATE INDEX "ai_usage_records_tenantId_provider_model_idx" ON "ai_usage_records" ("tenantId", "provider", "model");
66
+ CREATE INDEX "ai_usage_records_tenantId_systemCostCategory_idx" ON "ai_usage_records" ("tenantId", "systemCostCategory");
67
+ CREATE INDEX "ai_usage_records_tenantId_beneficiaryType_beneficiaryId_idx" ON "ai_usage_records" ("tenantId", "beneficiaryType", "beneficiaryId");
68
+ CREATE INDEX "ai_usage_records_priceVersionId_idx" ON "ai_usage_records" ("priceVersionId");
69
+ CREATE INDEX "ai_usage_records_status_createdAt_idx" ON "ai_usage_records" ("status", "createdAt");
70
+
71
+ -- ---------------------------------------------------------------------------
72
+ -- ai_model_prices — effective-dated price registry.
73
+ -- ---------------------------------------------------------------------------
74
+ CREATE TABLE "ai_model_prices" (
75
+ "id" TEXT NOT NULL,
76
+ "provider" TEXT NOT NULL,
77
+ "model" TEXT NOT NULL,
78
+ "operation" TEXT NOT NULL,
79
+ "serviceTier" TEXT NOT NULL DEFAULT 'standard',
80
+ "inputNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
81
+ "outputNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
82
+ "cacheReadNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
83
+ "cacheWrite5mNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
84
+ "cacheWrite1hNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
85
+ "reasoningNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
86
+ "audioInNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
87
+ "audioOutNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
88
+ "imageInNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
89
+ "imageOutNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
90
+ "tierThresholdTokens" INTEGER,
91
+ "tierInputNanoUsdPerMillion" BIGINT,
92
+ "tierOutputNanoUsdPerMillion" BIGINT,
93
+ "unitRates" JSONB,
94
+ "currency" TEXT NOT NULL DEFAULT 'USD',
95
+ "effectiveFrom" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
96
+ "effectiveTo" TIMESTAMP(3),
97
+ "source" TEXT NOT NULL DEFAULT 'snapshot',
98
+ CONSTRAINT "ai_model_prices_pkey" PRIMARY KEY ("id")
99
+ );
100
+ CREATE INDEX "ai_model_prices_provider_model_operation_serviceTier_effecti_idx" ON "ai_model_prices" ("provider", "model", "operation", "serviceTier", "effectiveFrom");
101
+
102
+ -- ---------------------------------------------------------------------------
103
+ -- ai_wallets + ai_wallet_entries + ai_wallet_debit_allocations (wallet feature).
104
+ -- ---------------------------------------------------------------------------
105
+ CREATE TABLE "ai_wallets" (
106
+ "id" TEXT NOT NULL,
107
+ "tenantId" TEXT NOT NULL,
108
+ "ownerType" TEXT NOT NULL,
109
+ "ownerId" TEXT NOT NULL,
110
+ "balanceNanoUsd" BIGINT NOT NULL DEFAULT 0,
111
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
112
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
113
+ CONSTRAINT "ai_wallets_pkey" PRIMARY KEY ("id")
114
+ );
115
+ CREATE UNIQUE INDEX "ai_wallets_tenantId_ownerType_ownerId_key" ON "ai_wallets" ("tenantId", "ownerType", "ownerId");
116
+
117
+ CREATE TABLE "ai_wallet_entries" (
118
+ "id" TEXT NOT NULL,
119
+ "walletId" TEXT NOT NULL,
120
+ "type" TEXT NOT NULL,
121
+ "amountNanoUsd" BIGINT NOT NULL,
122
+ "priority" INTEGER NOT NULL DEFAULT 0,
123
+ "effectiveAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
124
+ "expiresAt" TIMESTAMP(3),
125
+ "usageRecordId" TEXT,
126
+ "idempotencyKey" TEXT NOT NULL,
127
+ "reason" TEXT,
128
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
129
+ CONSTRAINT "ai_wallet_entries_pkey" PRIMARY KEY ("id")
130
+ );
131
+ CREATE UNIQUE INDEX "ai_wallet_entries_walletId_idempotencyKey_key" ON "ai_wallet_entries" ("walletId", "idempotencyKey");
132
+ CREATE INDEX "ai_wallet_entries_walletId_effectiveAt_idx" ON "ai_wallet_entries" ("walletId", "effectiveAt");
133
+ CREATE INDEX "ai_wallet_entries_walletId_type_expiresAt_idx" ON "ai_wallet_entries" ("walletId", "type", "expiresAt");
134
+
135
+ CREATE TABLE "ai_wallet_debit_allocations" (
136
+ "id" TEXT NOT NULL,
137
+ "debitEntryId" TEXT NOT NULL,
138
+ "grantEntryId" TEXT NOT NULL,
139
+ "amountNanoUsd" BIGINT NOT NULL,
140
+ CONSTRAINT "ai_wallet_debit_allocations_pkey" PRIMARY KEY ("id")
141
+ );
142
+ CREATE INDEX "ai_wallet_debit_allocations_grantEntryId_idx" ON "ai_wallet_debit_allocations" ("grantEntryId");
143
+ CREATE INDEX "ai_wallet_debit_allocations_debitEntryId_idx" ON "ai_wallet_debit_allocations" ("debitEntryId");
144
+
145
+ -- ---------------------------------------------------------------------------
146
+ -- ai_budgets + ai_budget_windows (budget feature).
147
+ -- ---------------------------------------------------------------------------
148
+ CREATE TABLE "ai_budgets" (
149
+ "id" TEXT NOT NULL,
150
+ "tenantId" TEXT NOT NULL,
151
+ "scopeType" TEXT NOT NULL,
152
+ "scopeId" TEXT NOT NULL,
153
+ "features" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
154
+ "limitNanoUsd" BIGINT,
155
+ "limitTokens" BIGINT,
156
+ "limitCount" INTEGER,
157
+ "window" TEXT NOT NULL,
158
+ "anchorAt" TIMESTAMP(3),
159
+ "expiresAt" TIMESTAMP(3),
160
+ "softThresholds" JSONB NOT NULL,
161
+ "policy" TEXT NOT NULL DEFAULT 'block',
162
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
163
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
164
+ CONSTRAINT "ai_budgets_pkey" PRIMARY KEY ("id")
165
+ );
166
+ CREATE INDEX "ai_budgets_tenantId_scopeType_scopeId_idx" ON "ai_budgets" ("tenantId", "scopeType", "scopeId");
167
+
168
+ CREATE TABLE "ai_budget_windows" (
169
+ "id" TEXT NOT NULL,
170
+ "budgetId" TEXT NOT NULL,
171
+ "windowStart" TIMESTAMP(3) NOT NULL,
172
+ "spentNanoUsd" BIGINT NOT NULL DEFAULT 0,
173
+ "spentTokens" BIGINT NOT NULL DEFAULT 0,
174
+ "spentCount" INTEGER NOT NULL DEFAULT 0,
175
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
176
+ CONSTRAINT "ai_budget_windows_pkey" PRIMARY KEY ("id")
177
+ );
178
+ CREATE UNIQUE INDEX "ai_budget_windows_budgetId_windowStart_key" ON "ai_budget_windows" ("budgetId", "windowStart");
179
+
180
+ -- Foreign keys for the wallet/budget relation graph.
181
+ ALTER TABLE "ai_wallet_entries" ADD CONSTRAINT "ai_wallet_entries_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "ai_wallets" ("id") ON DELETE RESTRICT ON UPDATE CASCADE;
182
+ ALTER TABLE "ai_wallet_debit_allocations" ADD CONSTRAINT "ai_wallet_debit_allocations_debitEntryId_fkey" FOREIGN KEY ("debitEntryId") REFERENCES "ai_wallet_entries" ("id") ON DELETE RESTRICT ON UPDATE CASCADE;
183
+ ALTER TABLE "ai_wallet_debit_allocations" ADD CONSTRAINT "ai_wallet_debit_allocations_grantEntryId_fkey" FOREIGN KEY ("grantEntryId") REFERENCES "ai_wallet_entries" ("id") ON DELETE RESTRICT ON UPDATE CASCADE;
184
+ ALTER TABLE "ai_budget_windows" ADD CONSTRAINT "ai_budget_windows_budgetId_fkey" FOREIGN KEY ("budgetId") REFERENCES "ai_budgets" ("id") ON DELETE RESTRICT ON UPDATE CASCADE;
185
+
186
+ -- ---------------------------------------------------------------------------
187
+ -- PARTIAL indexes (PostgreSQL-only; not expressible in the Prisma schema).
188
+ -- ---------------------------------------------------------------------------
189
+ -- Exactly one OPEN price row per (provider, model, operation, serviceTier) — the
190
+ -- seed/upsert race guard that makes the effective-dated close-and-insert atomic.
191
+ CREATE UNIQUE INDEX "ai_model_prices_open_row_key"
192
+ ON "ai_model_prices" ("provider", "model", "operation", "serviceTier")
193
+ WHERE "effectiveTo" IS NULL;
194
+
195
+ -- Fast reaper scan over pending holds only.
196
+ CREATE INDEX "ai_usage_records_pending_createdAt_idx"
197
+ ON "ai_usage_records" ("createdAt")
198
+ WHERE "status" = 'pending';
@@ -0,0 +1,197 @@
1
+ // @bymax-one/nest-ai-tokens — Prisma schema fragment (spec §15.3).
2
+ //
3
+ // The library owns its tables but not your ORM. The supported merge path is
4
+ // Prisma's MULTI-FILE SCHEMA (requires Prisma >= 6):
5
+ //
6
+ // 1. Copy this file into your host schema folder, e.g.
7
+ // `prisma/schema/ai-tokens.prisma` (keep your `datasource`/`generator`
8
+ // blocks in your main `prisma/schema/schema.prisma`).
9
+ // 2. Run `prisma migrate dev` — Prisma diffs these seven models and emits the
10
+ // migration for you.
11
+ //
12
+ // Hosts that manage SQL by hand instead apply the shipped
13
+ // `migrations/0001_init.sql` (which additionally creates the two PostgreSQL
14
+ // PARTIAL indexes that Prisma cannot express). Library upgrades that change the
15
+ // models ship new incremental SQL files plus a documented fragment diff.
16
+ //
17
+ // All money is `BigInt` nano-USD; every table carries `tenantId`. Table names are
18
+ // snake_case (via `@@map`); columns keep their camelCase field names — the
19
+ // official `PrismaAiTokensStore` adapter references them exactly as written here.
20
+
21
+ model AiUsageRecord {
22
+ id String @id @default(uuid())
23
+ tenantId String
24
+ scopeType String // tenant | team | user | key (the payer)
25
+ scopeId String
26
+ beneficiaryType String?
27
+ beneficiaryId String?
28
+ requestedBy String?
29
+ provider String
30
+ model String
31
+ requestedModel String?
32
+ operation String
33
+ serviceTier String @default("standard")
34
+ feature String
35
+ tags String[] @default([])
36
+ inputTokens Int @default(0)
37
+ outputTokens Int @default(0)
38
+ cacheReadTokens Int @default(0)
39
+ cacheWrite5mTokens Int @default(0)
40
+ cacheWrite1hTokens Int @default(0)
41
+ reasoningTokens Int @default(0)
42
+ audioInTokens Int @default(0)
43
+ audioOutTokens Int @default(0)
44
+ imageInTokens Int @default(0)
45
+ imageOutTokens Int @default(0)
46
+ totalTokens Int
47
+ extraUnits Json? // Record<string, number>
48
+ priceVersionId String? // no FK by design: records outlive price-row lifecycle; see index below
49
+ rawCostNanoUsd BigInt
50
+ surchargeNanoUsd BigInt @default(0)
51
+ billedCostNanoUsd BigInt
52
+ markupMultiplier Decimal @db.Decimal(10, 4)
53
+ currency String @default("USD")
54
+ priceMissing Boolean @default(false)
55
+ status String @default("posted") // record() fast path; holds insert 'pending'
56
+ reversedByRecordId String?
57
+ reversesRecordId String?
58
+ idempotencyKey String
59
+ payloadHash String
60
+ correlationId String?
61
+ requestId String?
62
+ isSystemCost Boolean @default(false)
63
+ systemCostCategory String?
64
+ enforced Boolean @default(false)
65
+ prevHash String?
66
+ hash String?
67
+ occurredAt DateTime
68
+ createdAt DateTime @default(now())
69
+ updatedAt DateTime @updatedAt
70
+
71
+ @@unique([tenantId, idempotencyKey])
72
+ @@index([tenantId, occurredAt])
73
+ @@index([tenantId, scopeType, scopeId, occurredAt])
74
+ @@index([tenantId, feature, occurredAt])
75
+ @@index([tenantId, provider, model])
76
+ @@index([tenantId, systemCostCategory])
77
+ @@index([tenantId, beneficiaryType, beneficiaryId])
78
+ @@index([priceVersionId])
79
+ @@index([status, createdAt]) // reaper scan; Postgres partial index (WHERE status = 'pending') in the shipped SQL
80
+ @@map("ai_usage_records")
81
+ }
82
+
83
+ model AiModelPrice {
84
+ id String @id @default(uuid())
85
+ provider String
86
+ model String
87
+ operation String
88
+ serviceTier String @default("standard")
89
+ inputNanoUsdPerMillion BigInt @default(0)
90
+ outputNanoUsdPerMillion BigInt @default(0)
91
+ cacheReadNanoUsdPerMillion BigInt @default(0)
92
+ cacheWrite5mNanoUsdPerMillion BigInt @default(0)
93
+ cacheWrite1hNanoUsdPerMillion BigInt @default(0)
94
+ reasoningNanoUsdPerMillion BigInt @default(0)
95
+ audioInNanoUsdPerMillion BigInt @default(0)
96
+ audioOutNanoUsdPerMillion BigInt @default(0)
97
+ imageInNanoUsdPerMillion BigInt @default(0)
98
+ imageOutNanoUsdPerMillion BigInt @default(0)
99
+ tierThresholdTokens Int?
100
+ tierInputNanoUsdPerMillion BigInt?
101
+ tierOutputNanoUsdPerMillion BigInt?
102
+ unitRates Json? // Record<string, string> — nano-USD per unit as decimal strings
103
+ currency String @default("USD")
104
+ effectiveFrom DateTime @default(now())
105
+ effectiveTo DateTime?
106
+ source String @default("snapshot")
107
+
108
+ @@index([provider, model, operation, serviceTier, effectiveFrom])
109
+ // Shipped SQL adds: CREATE UNIQUE INDEX ... ON ai_model_prices ("provider", "model", "operation", "serviceTier")
110
+ // WHERE "effectiveTo" IS NULL; -- exactly one open row per key (seed/upsert race guard)
111
+ @@map("ai_model_prices")
112
+ }
113
+
114
+ model AiWallet {
115
+ id String @id @default(uuid())
116
+ tenantId String
117
+ ownerType String // tenant | team | user
118
+ ownerId String
119
+ balanceNanoUsd BigInt @default(0) // materialized; source of truth = Σ entries (§9.4)
120
+ createdAt DateTime @default(now())
121
+ updatedAt DateTime @updatedAt
122
+ entries AiWalletEntry[]
123
+
124
+ @@unique([tenantId, ownerType, ownerId])
125
+ @@map("ai_wallets")
126
+ }
127
+
128
+ model AiWalletEntry {
129
+ id String @id @default(uuid())
130
+ walletId String
131
+ type String // grant | debit | refund | adjustment | expiry
132
+ amountNanoUsd BigInt
133
+ priority Int @default(0)
134
+ effectiveAt DateTime @default(now())
135
+ expiresAt DateTime?
136
+ usageRecordId String?
137
+ idempotencyKey String
138
+ reason String?
139
+ createdAt DateTime @default(now())
140
+ wallet AiWallet @relation(fields: [walletId], references: [id])
141
+ allocationsAsDebit AiWalletDebitAllocation[] @relation("debit")
142
+ allocationsAsGrant AiWalletDebitAllocation[] @relation("grant")
143
+
144
+ @@unique([walletId, idempotencyKey])
145
+ @@index([walletId, effectiveAt])
146
+ @@index([walletId, type, expiresAt])
147
+ @@map("ai_wallet_entries")
148
+ }
149
+
150
+ model AiWalletDebitAllocation {
151
+ id String @id @default(uuid())
152
+ debitEntryId String
153
+ grantEntryId String
154
+ amountNanoUsd BigInt
155
+ debitEntry AiWalletEntry @relation("debit", fields: [debitEntryId], references: [id])
156
+ grantEntry AiWalletEntry @relation("grant", fields: [grantEntryId], references: [id])
157
+
158
+ @@index([grantEntryId])
159
+ @@index([debitEntryId])
160
+ @@map("ai_wallet_debit_allocations")
161
+ }
162
+
163
+ model AiBudget {
164
+ id String @id @default(uuid())
165
+ tenantId String
166
+ scopeType String
167
+ scopeId String
168
+ features String[] @default([])
169
+ limitNanoUsd BigInt?
170
+ limitTokens BigInt?
171
+ limitCount Int?
172
+ window String // day | week | month | total | custom:<seconds>
173
+ anchorAt DateTime?
174
+ expiresAt DateTime?
175
+ softThresholds Json // number[]
176
+ policy String @default("block")
177
+ createdAt DateTime @default(now())
178
+ updatedAt DateTime @updatedAt
179
+ windows AiBudgetWindow[]
180
+
181
+ @@index([tenantId, scopeType, scopeId])
182
+ @@map("ai_budgets")
183
+ }
184
+
185
+ model AiBudgetWindow {
186
+ id String @id @default(uuid())
187
+ budgetId String
188
+ windowStart DateTime
189
+ spentNanoUsd BigInt @default(0)
190
+ spentTokens BigInt @default(0) // BigInt: 'total' windows on busy tenants exceed int4
191
+ spentCount Int @default(0)
192
+ updatedAt DateTime @updatedAt
193
+ budget AiBudget @relation(fields: [budgetId], references: [id])
194
+
195
+ @@unique([budgetId, windowStart])
196
+ @@map("ai_budget_windows")
197
+ }
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ // src/redis/index.ts
4
+ var SAFE_INTEGER_MAX = "9007199254740991";
5
+ var INCR_IF_BELOW_LUA = `
6
+ local amount = tonumber(ARGV[1])
7
+ local limit = tonumber(ARGV[2])
8
+ if amount > ${SAFE_INTEGER_MAX} or amount < -${SAFE_INTEGER_MAX} or limit > ${SAFE_INTEGER_MAX} or limit < -${SAFE_INTEGER_MAX} then
9
+ return -1
10
+ end
11
+ local newValue = redis.call('INCRBY', KEYS[1], ARGV[1])
12
+ if newValue > ${SAFE_INTEGER_MAX} or newValue < -${SAFE_INTEGER_MAX} then
13
+ redis.call('DECRBY', KEYS[1], ARGV[1])
14
+ return -1
15
+ end
16
+ if newValue > limit then
17
+ redis.call('DECRBY', KEYS[1], ARGV[1])
18
+ return 0
19
+ end
20
+ redis.call('PEXPIRE', KEYS[1], ARGV[3])
21
+ return 1
22
+ `;
23
+ var DECR_FLOOR_LUA = `
24
+ local newValue = redis.call('DECRBY', KEYS[1], ARGV[1])
25
+ if newValue < 0 then
26
+ redis.call('SET', KEYS[1], '0')
27
+ end
28
+ return 1
29
+ `;
30
+ var CounterValueOutOfRangeError = class extends Error {
31
+ isCounterValueOutOfRange = true;
32
+ constructor() {
33
+ super("counter operand or result exceeds the safe-integer range");
34
+ this.name = "CounterValueOutOfRangeError";
35
+ }
36
+ };
37
+ var RedisBudgetCounterStore = class {
38
+ keyPrefix;
39
+ source;
40
+ connecting = null;
41
+ /**
42
+ * @param redis A live `ioredis` client, or a connection URL to lazily connect.
43
+ * @param options Optional key prefix.
44
+ */
45
+ constructor(redis, options = {}) {
46
+ this.keyPrefix = options.keyPrefix ?? "";
47
+ this.source = redis;
48
+ }
49
+ /**
50
+ * Atomically increment `key` by `amount` iff the result stays `<= limit`, setting
51
+ * the TTL. Returns `false` when the increment would exceed the limit.
52
+ *
53
+ * @param key The dimension counter key.
54
+ * @param amount The amount to add (int64 nano-USD or token/count units).
55
+ * @param limit The dimension limit.
56
+ * @param ttlSeconds The key TTL (window length + grace).
57
+ * @returns `true` when incremented within the limit, `false` when it would exceed it.
58
+ * @throws {CounterValueOutOfRangeError} when an operand or the resulting counter exceeds the safe-integer range.
59
+ */
60
+ async incrIfBelow(key, amount, limit, ttlSeconds) {
61
+ const client = await this.resolve();
62
+ const result = await client.eval(
63
+ INCR_IF_BELOW_LUA,
64
+ 1,
65
+ this.prefixed(key),
66
+ amount.toString(),
67
+ limit.toString(),
68
+ Math.ceil(ttlSeconds * 1e3).toString()
69
+ );
70
+ if (result === -1) throw new CounterValueOutOfRangeError();
71
+ return result === 1;
72
+ }
73
+ /**
74
+ * Decrement `key` by `amount`, flooring the stored value at zero (release/reverse).
75
+ *
76
+ * @param key The dimension counter key.
77
+ * @param amount The amount to subtract.
78
+ */
79
+ async decr(key, amount) {
80
+ const client = await this.resolve();
81
+ await client.eval(DECR_FLOOR_LUA, 1, this.prefixed(key), amount.toString());
82
+ }
83
+ /**
84
+ * Reset (delete) `key` — used on window rotation.
85
+ *
86
+ * @param key The dimension counter key.
87
+ */
88
+ async reset(key) {
89
+ const client = await this.resolve();
90
+ await client.del(this.prefixed(key));
91
+ }
92
+ /** Prepend the configured key prefix. */
93
+ prefixed(key) {
94
+ return `${this.keyPrefix}${key}`;
95
+ }
96
+ /** Resolve the live client, lazily connecting a URL via a dynamic `ioredis` import. */
97
+ resolve() {
98
+ if (typeof this.source !== "string") return Promise.resolve(this.source);
99
+ this.connecting ??= this.connect(this.source);
100
+ return this.connecting;
101
+ }
102
+ /** Dynamically import `ioredis` and connect the given URL. */
103
+ async connect(url) {
104
+ const { default: RedisClient } = await import('ioredis');
105
+ return new RedisClient(url);
106
+ }
107
+ };
108
+
109
+ exports.CounterValueOutOfRangeError = CounterValueOutOfRangeError;
110
+ exports.RedisBudgetCounterStore = RedisBudgetCounterStore;
@@ -0,0 +1,98 @@
1
+ import { Redis } from 'ioredis';
2
+
3
+ /**
4
+ * @fileoverview The optional live cross-replica budget counter port (spec §15.1,
5
+ * §10.8). The fast path for budget enforcement; the DB conditional consume is the
6
+ * fallback. Values are serialized as int64 decimal strings. Implemented by the
7
+ * official Redis adapter (`./redis`).
8
+ * @layer server
9
+ */
10
+ /** The live budget counter port. */
11
+ interface IBudgetCounterStore {
12
+ /**
13
+ * Atomically increment `key` by `amount` iff the result stays `<= limit`,
14
+ * setting the TTL. Returns `false` when the increment would exceed the limit.
15
+ */
16
+ incrIfBelow(key: string, amount: bigint, limit: bigint, ttlSeconds: number): Promise<boolean>;
17
+ /** Decrement `key` by `amount` (release/reverse). */
18
+ decr(key: string, amount: bigint): Promise<void>;
19
+ /** Reset `key` (window rotation). */
20
+ reset(key: string): Promise<void>;
21
+ }
22
+
23
+ /**
24
+ * @fileoverview `RedisBudgetCounterStore` — the optional live cross-replica budget
25
+ * counter (`./redis` subpath, spec §10.8, §15.1). It makes budget enforcement cheap
26
+ * under high concurrency: `incrIfBelow` is a SINGLE atomic Lua script (increment,
27
+ * then undo and reject when the result would exceed the limit), so two racing
28
+ * replicas can never both pass a nearly-full budget. The DB conditional consume
29
+ * stays authoritative — this counter is the fast path, not the source of truth.
30
+ * Values are int64 (Redis native) written/compared as decimal strings; keys carry
31
+ * the caller's optional prefix. `ioredis` is an OPTIONAL peer: the constructor
32
+ * accepts a live `Redis` instance, or a connection URL that is lazily connected via
33
+ * a DYNAMIC import so the base library never statically depends on the client.
34
+ * Connection URLs (which may embed credentials) are never logged or placed on an
35
+ * exception.
36
+ * @layer redis
37
+ */
38
+
39
+ /** Construction options for {@link RedisBudgetCounterStore}. */
40
+ interface RedisBudgetCounterStoreOptions {
41
+ /** Prepended to every counter key (namespacing a shared Redis). */
42
+ keyPrefix?: string;
43
+ }
44
+ /**
45
+ * Signals that a counter operand or result falls outside the IEEE-754 safe-integer
46
+ * range (`±(2^53 − 1)`), where the Lua/Redis double arithmetic the fast path relies
47
+ * on would silently lose precision. It is thrown so `BudgetService` treats the counter
48
+ * as unavailable and falls back to the authoritative DB conditional consume, which
49
+ * compares int64 nano-USD exactly.
50
+ */
51
+ declare class CounterValueOutOfRangeError extends Error {
52
+ readonly isCounterValueOutOfRange = true;
53
+ constructor();
54
+ }
55
+ /** The official Redis adapter for the live budget counter port. */
56
+ declare class RedisBudgetCounterStore implements IBudgetCounterStore {
57
+ private readonly keyPrefix;
58
+ private readonly source;
59
+ private connecting;
60
+ /**
61
+ * @param redis A live `ioredis` client, or a connection URL to lazily connect.
62
+ * @param options Optional key prefix.
63
+ */
64
+ constructor(redis: Redis | string, options?: RedisBudgetCounterStoreOptions);
65
+ /**
66
+ * Atomically increment `key` by `amount` iff the result stays `<= limit`, setting
67
+ * the TTL. Returns `false` when the increment would exceed the limit.
68
+ *
69
+ * @param key The dimension counter key.
70
+ * @param amount The amount to add (int64 nano-USD or token/count units).
71
+ * @param limit The dimension limit.
72
+ * @param ttlSeconds The key TTL (window length + grace).
73
+ * @returns `true` when incremented within the limit, `false` when it would exceed it.
74
+ * @throws {CounterValueOutOfRangeError} when an operand or the resulting counter exceeds the safe-integer range.
75
+ */
76
+ incrIfBelow(key: string, amount: bigint, limit: bigint, ttlSeconds: number): Promise<boolean>;
77
+ /**
78
+ * Decrement `key` by `amount`, flooring the stored value at zero (release/reverse).
79
+ *
80
+ * @param key The dimension counter key.
81
+ * @param amount The amount to subtract.
82
+ */
83
+ decr(key: string, amount: bigint): Promise<void>;
84
+ /**
85
+ * Reset (delete) `key` — used on window rotation.
86
+ *
87
+ * @param key The dimension counter key.
88
+ */
89
+ reset(key: string): Promise<void>;
90
+ /** Prepend the configured key prefix. */
91
+ private prefixed;
92
+ /** Resolve the live client, lazily connecting a URL via a dynamic `ioredis` import. */
93
+ private resolve;
94
+ /** Dynamically import `ioredis` and connect the given URL. */
95
+ private connect;
96
+ }
97
+
98
+ export { CounterValueOutOfRangeError, RedisBudgetCounterStore, type RedisBudgetCounterStoreOptions };