@rulvar/store-postgres 1.94.0 → 1.95.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/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { JournalEntry, LeasableStore, Lease, MetaLookupStore, RunFilter, RunMeta, TranscriptStore } from "@rulvar/core";
1
+ import { JournalEntry, LeasableStore, Lease, MetaLookupStore, QuotaDecision, QuotaLimiter, QuotaReservationRequest, QuotaRule, RunFilter, RunMeta, TranscriptStore, Usage } from "@rulvar/core";
2
2
 
3
3
  //#region src/store.d.ts
4
4
  /** Appendix A interim reference, shared with the sqlite store. */
@@ -112,4 +112,90 @@ declare class PostgresStore implements MetaLookupStore, LeasableStore {
112
112
  release(l: Lease): Promise<void>;
113
113
  }
114
114
  //#endregion
115
- export { DEFAULT_LEASE_TTL_MS, DEFAULT_POOL_MAX, PostgresStore, type PostgresStoreOptions, type PostgresTranscriptStore };
115
+ //#region src/quota.d.ts
116
+ /**
117
+ * How long a reserve/reconcile transaction waits for the schema-wide
118
+ * admission lock before postgres cancels the statement. Quota
119
+ * admissions are short single-writer transactions; queueing here IS
120
+ * the cross-host serialization working.
121
+ */
122
+ declare const QUOTA_LOCK_TIMEOUT_MS = 2e3;
123
+ interface PostgresQuotaLimiterOptions {
124
+ /**
125
+ * A postgres connection string shared by every coordinating process
126
+ * and host (the database may also hold a PostgresStore; the tables
127
+ * do not collide).
128
+ */
129
+ url: string;
130
+ /**
131
+ * Schema holding the two quota tables; default `public`. A
132
+ * non-public schema is created on boot. Must be a plain SQL
133
+ * identifier.
134
+ */
135
+ schema?: string;
136
+ /** The shared rule set; must be identical across hosts. */
137
+ rules: readonly QuotaRule[];
138
+ /** Pool size ceiling; default 10. Admissions are short transactions. */
139
+ max?: number;
140
+ /** Injectable clock for window tests. */
141
+ now?: () => number;
142
+ }
143
+ /**
144
+ * The multi-host reference implementation of the core QuotaLimiter
145
+ * SPI: engine processes pointing instances at ONE database and schema
146
+ * (a PostgresStore's database or their own) enforce one global
147
+ * provider quota. Admission consumes the window counters inside a
148
+ * single transaction serialized on a schema-wide advisory transaction
149
+ * lock, so two processes or HOSTS can never both take the last slot;
150
+ * reservations are rows, so `reconcile` settles a grant from any host;
151
+ * both tables are lazily pruned to the current and previous accounting
152
+ * window. The rule model, the fixed epoch-aligned one-minute windows,
153
+ * and the admission decision are the core's own exported functions, so
154
+ * this limiter, `memoryQuotaLimiter`, and `SqliteQuotaLimiter` agree
155
+ * on every verdict. The `rules` MUST be identical across coordinating
156
+ * processes (buckets key on rule content). Runtime contention queues
157
+ * on the advisory lock (a hot limiter is EXPECTED to serialize); a
158
+ * call still waiting past `QUOTA_LOCK_TIMEOUT_MS` throws, and the
159
+ * engine's `onLimiterError` policy decides what that means. Call
160
+ * `close()` when done.
161
+ */
162
+ declare class PostgresQuotaLimiter implements QuotaLimiter {
163
+ private readonly pool;
164
+ private readonly schema;
165
+ private readonly rules;
166
+ private readonly now;
167
+ private boot;
168
+ constructor(options: PostgresQuotaLimiterOptions);
169
+ /** `"schema".rulvar_<name>`, always schema-qualified. */
170
+ private table;
171
+ /**
172
+ * The lazy idempotent bootstrap, memoized so it runs once per
173
+ * limiter; a rejected boot clears the memo so the next call retries.
174
+ * The schema-scoped advisory transaction lock serializes a fleet of
175
+ * processes bootstrapping the same fresh database (the PostgresStore
176
+ * pattern: postgres queues on the lock and needs no busy retry).
177
+ */
178
+ private booted;
179
+ private runBootstrap;
180
+ /**
181
+ * One serialized admission transaction: BEGIN, bound the lock wait,
182
+ * take the schema-wide quota advisory lock, run `fn`, COMMIT. Every
183
+ * counter mutation goes through here, which is what makes the
184
+ * verdict read and the consume one unit across processes and hosts.
185
+ */
186
+ private withQuotaLock;
187
+ reserve(request: QuotaReservationRequest): Promise<QuotaDecision>;
188
+ reconcile(reservationId: string, usage: Usage): Promise<void>;
189
+ /** Current-window counters per rule, for telemetry and referees. */
190
+ snapshot(): Promise<Array<{
191
+ rule: QuotaRule;
192
+ windowStart: number;
193
+ requests: number;
194
+ tokens: number;
195
+ }>>;
196
+ close(): Promise<void>;
197
+ /** Both tables stay bounded to the current and previous window. */
198
+ private prune;
199
+ }
200
+ //#endregion
201
+ export { DEFAULT_LEASE_TTL_MS, DEFAULT_POOL_MAX, PostgresQuotaLimiter, type PostgresQuotaLimiterOptions, PostgresStore, type PostgresStoreOptions, type PostgresTranscriptStore, QUOTA_LOCK_TIMEOUT_MS };
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import pg from "pg";
2
- import { ConfigError, JournalOrderViolation, LeaseHeldError, metaMatchesFilter } from "@rulvar/core";
2
+ import { ConfigError, JournalOrderViolation, LeaseHeldError, QUOTA_WINDOW_MS, mergeQuotaDenial, metaMatchesFilter, quotaActualTokens, quotaEstimateTokens, quotaRuleAdmission, quotaRuleMatches, validateQuotaRules } from "@rulvar/core";
3
+ import { randomUUID } from "node:crypto";
3
4
  //#region src/store.ts
4
5
  /**
5
6
  * PostgresStore (RV-214): JournalStore plus LeasableStore with fencing
@@ -56,9 +57,12 @@ import { ConfigError, JournalOrderViolation, LeaseHeldError, metaMatchesFilter }
56
57
  const DEFAULT_LEASE_TTL_MS = 6e4;
57
58
  /** Default pg Pool size; every operation is a short transaction. */
58
59
  const DEFAULT_POOL_MAX = 10;
59
- /** The advisory-lock hash seed; a constant namespace for this store. */
60
+ /**
61
+ * The advisory-lock hash seed; a constant namespace shared by this
62
+ * store and the quota limiter beside it (their key strings differ).
63
+ */
60
64
  const LOCK_SEED = 8214;
61
- const wallClock = Date.now.bind(globalThis);
65
+ const wallClock$1 = Date.now.bind(globalThis);
62
66
  /** A conservative SQL identifier gate for the schema option. */
63
67
  const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
64
68
  var PostgresStore = class {
@@ -89,7 +93,7 @@ var PostgresStore = class {
89
93
  if (options.max !== void 0 && (!Number.isInteger(options.max) || options.max < 1)) throw new ConfigError(`PostgresStoreOptions.max must be a positive integer; got ${String(options.max)}`);
90
94
  this.schema = schema;
91
95
  this.ttlMs = ttlMs;
92
- this.now = options.now ?? wallClock;
96
+ this.now = options.now ?? wallClock$1;
93
97
  this.pool = new pg.Pool({
94
98
  connectionString: options.url,
95
99
  max: options.max ?? 10
@@ -408,4 +412,275 @@ var PostgresStore = class {
408
412
  }
409
413
  };
410
414
  //#endregion
411
- export { DEFAULT_LEASE_TTL_MS, DEFAULT_POOL_MAX, PostgresStore };
415
+ //#region src/quota.ts
416
+ /**
417
+ * PostgresQuotaLimiter (RV410): the multi-host reference
418
+ * implementation of the core QuotaLimiter SPI over node-postgres.
419
+ * Engine processes on ANY number of hosts pointing instances at one
420
+ * database and schema enforce ONE global quota: admission consumes the
421
+ * window counters inside a single transaction that first takes a
422
+ * schema-wide advisory transaction lock (the PostgresStore translation
423
+ * of the sqlite BEGIN IMMEDIATE lesson: checking in one statement and
424
+ * mutating in the next leaves a window where two admitters both read
425
+ * the last slot), so two hosts can never both take it.
426
+ *
427
+ * Contract highlights:
428
+ * - The rule model, window math, and admission decision are the
429
+ * core's own (`quotaRuleAdmission` over fixed epoch-aligned
430
+ * one-minute windows), so this limiter, `memoryQuotaLimiter`, and
431
+ * `SqliteQuotaLimiter` agree byte-for-byte on every verdict.
432
+ * - Buckets key on the rule CONTENT (a canonical fixed-order JSON of
433
+ * the rule), not on array position: every process sharing the
434
+ * database must configure the same rules, and equal rules land on
435
+ * the same bucket regardless of order.
436
+ * - Reservations are rows, so reconcile works from any host. A
437
+ * crashed process that never reconciles leaves its estimate in the
438
+ * window until the window ages out; the lazy prune keeps both
439
+ * tables bounded to two windows.
440
+ * - Runtime contention queues on the advisory lock instead of failing
441
+ * raw: a hot limiter is EXPECTED to serialize admissions. A call
442
+ * still waiting past `QUOTA_LOCK_TIMEOUT_MS` throws (postgres
443
+ * cancels the statement under `SET LOCAL lock_timeout`), and the
444
+ * engine's `onLimiterError` policy decides what that means.
445
+ * - The durable admission QUEUE stays the host's: a denial carries the
446
+ * honest window remainder, and what to do while waiting (park the
447
+ * run, spill to another provider, surface backpressure) is host
448
+ * policy, exactly as documented for the other references.
449
+ *
450
+ * Docs: https://docs.rulvar.com/guide/model-routing
451
+ */
452
+ /**
453
+ * How long a reserve/reconcile transaction waits for the schema-wide
454
+ * admission lock before postgres cancels the statement. Quota
455
+ * admissions are short single-writer transactions; queueing here IS
456
+ * the cross-host serialization working.
457
+ */
458
+ const QUOTA_LOCK_TIMEOUT_MS = 2e3;
459
+ const wallClock = Date.now.bind(globalThis);
460
+ /**
461
+ * The canonical bucket key of one rule: a fixed-field-order JSON of
462
+ * its content, identical across hosts for identical rules (and
463
+ * identical to the SqliteQuotaLimiter encoding).
464
+ */
465
+ function ruleKey(rule) {
466
+ return JSON.stringify({
467
+ provider: rule.provider ?? null,
468
+ model: rule.model ?? null,
469
+ tenant: rule.tenant ?? null,
470
+ requestsPerMinute: rule.requestsPerMinute ?? null,
471
+ tokensPerMinute: rule.tokensPerMinute ?? null
472
+ });
473
+ }
474
+ /**
475
+ * The multi-host reference implementation of the core QuotaLimiter
476
+ * SPI: engine processes pointing instances at ONE database and schema
477
+ * (a PostgresStore's database or their own) enforce one global
478
+ * provider quota. Admission consumes the window counters inside a
479
+ * single transaction serialized on a schema-wide advisory transaction
480
+ * lock, so two processes or HOSTS can never both take the last slot;
481
+ * reservations are rows, so `reconcile` settles a grant from any host;
482
+ * both tables are lazily pruned to the current and previous accounting
483
+ * window. The rule model, the fixed epoch-aligned one-minute windows,
484
+ * and the admission decision are the core's own exported functions, so
485
+ * this limiter, `memoryQuotaLimiter`, and `SqliteQuotaLimiter` agree
486
+ * on every verdict. The `rules` MUST be identical across coordinating
487
+ * processes (buckets key on rule content). Runtime contention queues
488
+ * on the advisory lock (a hot limiter is EXPECTED to serialize); a
489
+ * call still waiting past `QUOTA_LOCK_TIMEOUT_MS` throws, and the
490
+ * engine's `onLimiterError` policy decides what that means. Call
491
+ * `close()` when done.
492
+ */
493
+ var PostgresQuotaLimiter = class {
494
+ pool;
495
+ schema;
496
+ rules;
497
+ now;
498
+ boot;
499
+ constructor(options) {
500
+ if (typeof options.url !== "string" || options.url === "") throw new ConfigError("PostgresQuotaLimiterOptions.url must be a nonempty connection string");
501
+ const schema = options.schema ?? "public";
502
+ if (!IDENTIFIER.test(schema)) throw new ConfigError(`PostgresQuotaLimiterOptions.schema must be a plain SQL identifier; got '${schema}'`);
503
+ validateQuotaRules(options.rules, "PostgresQuotaLimiterOptions.rules");
504
+ if (options.max !== void 0 && (!Number.isInteger(options.max) || options.max < 1)) throw new ConfigError(`PostgresQuotaLimiterOptions.max must be a positive integer; got ${String(options.max)}`);
505
+ this.schema = schema;
506
+ this.rules = options.rules;
507
+ this.now = options.now ?? wallClock;
508
+ this.pool = new pg.Pool({
509
+ connectionString: options.url,
510
+ max: options.max ?? 10
511
+ });
512
+ this.pool.on("error", () => void 0);
513
+ }
514
+ /** `"schema".rulvar_<name>`, always schema-qualified. */
515
+ table(name) {
516
+ return `"${this.schema}".rulvar_${name}`;
517
+ }
518
+ /**
519
+ * The lazy idempotent bootstrap, memoized so it runs once per
520
+ * limiter; a rejected boot clears the memo so the next call retries.
521
+ * The schema-scoped advisory transaction lock serializes a fleet of
522
+ * processes bootstrapping the same fresh database (the PostgresStore
523
+ * pattern: postgres queues on the lock and needs no busy retry).
524
+ */
525
+ booted() {
526
+ this.boot ??= this.runBootstrap().catch((thrown) => {
527
+ this.boot = void 0;
528
+ throw thrown;
529
+ });
530
+ return this.boot;
531
+ }
532
+ async runBootstrap() {
533
+ const client = await this.pool.connect();
534
+ try {
535
+ await client.query("BEGIN");
536
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, $2))", [`rulvar-quota-boot:${this.schema}`, LOCK_SEED]);
537
+ if (this.schema !== "public") await client.query(`CREATE SCHEMA IF NOT EXISTS "${this.schema}"`);
538
+ await client.query(`
539
+ CREATE TABLE IF NOT EXISTS ${this.table("quota_buckets")} (
540
+ rule_key TEXT NOT NULL,
541
+ window_start BIGINT NOT NULL,
542
+ requests BIGINT NOT NULL,
543
+ tokens BIGINT NOT NULL,
544
+ PRIMARY KEY (rule_key, window_start)
545
+ );
546
+ CREATE TABLE IF NOT EXISTS ${this.table("quota_reservations")} (
547
+ id TEXT PRIMARY KEY,
548
+ window_start BIGINT NOT NULL,
549
+ estimate_tokens BIGINT NOT NULL,
550
+ rule_keys TEXT NOT NULL
551
+ );
552
+ `);
553
+ await client.query("COMMIT");
554
+ } catch (thrown) {
555
+ await client.query("ROLLBACK").catch(() => void 0);
556
+ throw thrown;
557
+ } finally {
558
+ client.release();
559
+ }
560
+ }
561
+ /**
562
+ * One serialized admission transaction: BEGIN, bound the lock wait,
563
+ * take the schema-wide quota advisory lock, run `fn`, COMMIT. Every
564
+ * counter mutation goes through here, which is what makes the
565
+ * verdict read and the consume one unit across processes and hosts.
566
+ */
567
+ async withQuotaLock(fn) {
568
+ await this.booted();
569
+ const client = await this.pool.connect();
570
+ try {
571
+ await client.query("BEGIN");
572
+ await client.query(`SET LOCAL lock_timeout = '${String(QUOTA_LOCK_TIMEOUT_MS)}ms'`);
573
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, $2))", [`${this.schema}:rulvar-quota`, LOCK_SEED]);
574
+ const result = await fn(client);
575
+ await client.query("COMMIT");
576
+ return result;
577
+ } catch (thrown) {
578
+ await client.query("ROLLBACK").catch(() => void 0);
579
+ throw thrown;
580
+ } finally {
581
+ client.release();
582
+ }
583
+ }
584
+ reserve(request) {
585
+ const at = this.now();
586
+ const windowStart = at - at % QUOTA_WINDOW_MS;
587
+ const estimateTokens = quotaEstimateTokens(request);
588
+ return this.withQuotaLock(async (client) => {
589
+ await this.prune(client, windowStart);
590
+ const matched = [];
591
+ let denial;
592
+ for (const rule of this.rules) {
593
+ if (!quotaRuleMatches(rule, request)) continue;
594
+ const key = ruleKey(rule);
595
+ matched.push(key);
596
+ const row = (await client.query(`SELECT requests::int8 AS requests, tokens::int8 AS tokens
597
+ FROM ${this.table("quota_buckets")} WHERE rule_key = $1 AND window_start = $2`, [key, windowStart])).rows[0];
598
+ const verdict = quotaRuleAdmission(rule, row === void 0 ? {
599
+ requests: 0,
600
+ tokens: 0
601
+ } : {
602
+ requests: Number(row.requests),
603
+ tokens: Number(row.tokens)
604
+ }, {
605
+ requests: request.estimate.requests,
606
+ tokens: estimateTokens
607
+ }, windowStart + QUOTA_WINDOW_MS - at);
608
+ if (!verdict.admit) denial = mergeQuotaDenial(denial, verdict);
609
+ }
610
+ if (denial !== void 0) return {
611
+ granted: false,
612
+ ...denial
613
+ };
614
+ for (const key of matched) await client.query(`INSERT INTO ${this.table("quota_buckets")} AS b
615
+ (rule_key, window_start, requests, tokens) VALUES ($1, $2, $3, $4)
616
+ ON CONFLICT (rule_key, window_start) DO UPDATE SET
617
+ requests = b.requests + excluded.requests, tokens = b.tokens + excluded.tokens`, [
618
+ key,
619
+ windowStart,
620
+ request.estimate.requests,
621
+ estimateTokens
622
+ ]);
623
+ const reservationId = randomUUID();
624
+ await client.query(`INSERT INTO ${this.table("quota_reservations")}
625
+ (id, window_start, estimate_tokens, rule_keys) VALUES ($1, $2, $3, $4)`, [
626
+ reservationId,
627
+ windowStart,
628
+ estimateTokens,
629
+ JSON.stringify(matched)
630
+ ]);
631
+ return {
632
+ granted: true,
633
+ reservationId
634
+ };
635
+ });
636
+ }
637
+ reconcile(reservationId, usage) {
638
+ const at = this.now();
639
+ const windowStart = at - at % QUOTA_WINDOW_MS;
640
+ return this.withQuotaLock(async (client) => {
641
+ const row = (await client.query(`SELECT window_start::int8 AS window_start, estimate_tokens::int8 AS estimate_tokens,
642
+ rule_keys
643
+ FROM ${this.table("quota_reservations")} WHERE id = $1`, [reservationId])).rows[0];
644
+ if (row === void 0) return;
645
+ await client.query(`DELETE FROM ${this.table("quota_reservations")} WHERE id = $1`, [reservationId]);
646
+ if (Number(row.window_start) === windowStart) {
647
+ const delta = quotaActualTokens(usage) - Number(row.estimate_tokens);
648
+ for (const key of JSON.parse(row.rule_keys)) await client.query(`UPDATE ${this.table("quota_buckets")} SET tokens = GREATEST(0, tokens + $1)
649
+ WHERE rule_key = $2 AND window_start = $3`, [
650
+ delta,
651
+ key,
652
+ windowStart
653
+ ]);
654
+ }
655
+ });
656
+ }
657
+ /** Current-window counters per rule, for telemetry and referees. */
658
+ async snapshot() {
659
+ await this.booted();
660
+ const at = this.now();
661
+ const windowStart = at - at % QUOTA_WINDOW_MS;
662
+ const out = [];
663
+ for (const rule of this.rules) {
664
+ const row = (await this.pool.query(`SELECT requests::int8 AS requests, tokens::int8 AS tokens
665
+ FROM ${this.table("quota_buckets")} WHERE rule_key = $1 AND window_start = $2`, [ruleKey(rule), windowStart])).rows[0];
666
+ out.push({
667
+ rule,
668
+ windowStart,
669
+ requests: row === void 0 ? 0 : Number(row.requests),
670
+ tokens: row === void 0 ? 0 : Number(row.tokens)
671
+ });
672
+ }
673
+ return out;
674
+ }
675
+ async close() {
676
+ await this.pool.end();
677
+ }
678
+ /** Both tables stay bounded to the current and previous window. */
679
+ async prune(client, windowStart) {
680
+ const cutoff = windowStart - QUOTA_WINDOW_MS;
681
+ await client.query(`DELETE FROM ${this.table("quota_buckets")} WHERE window_start < $1`, [cutoff]);
682
+ await client.query(`DELETE FROM ${this.table("quota_reservations")} WHERE window_start < $1`, [cutoff]);
683
+ }
684
+ };
685
+ //#endregion
686
+ export { DEFAULT_LEASE_TTL_MS, DEFAULT_POOL_MAX, PostgresQuotaLimiter, PostgresStore, QUOTA_LOCK_TIMEOUT_MS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/store-postgres",
3
- "version": "1.94.0",
3
+ "version": "1.95.0",
4
4
  "description": "Rulvar PostgreSQL store implementing JournalStore and LeasableStore with a fencing epoch, for multi-process and multi-host deployments.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -23,14 +23,14 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "pg": "^8.22.0",
26
- "@rulvar/core": "1.94.0"
26
+ "@rulvar/core": "1.95.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^22.20.1",
30
30
  "@types/pg": "^8.20.0",
31
31
  "tsdown": "^0.22.14",
32
32
  "typescript": "~6.0.3",
33
- "@rulvar/store-conformance": "1.94.0"
33
+ "@rulvar/store-conformance": "1.95.0"
34
34
  },
35
35
  "repository": {
36
36
  "type": "git",