@rulvar/store-postgres 1.104.0 → 1.106.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
@@ -147,17 +147,56 @@ declare class QuotaDeadlineError extends Error {
147
147
  readonly deadlineMs: number;
148
148
  /** The schema whose admission missed it. */
149
149
  readonly schema: string;
150
- /** Where the path stood: acquiring a connection, or mid-transaction. */
151
- readonly phase: "acquire" | "transaction";
152
- constructor(deadlineMs: number, schema: string, phase: "acquire" | "transaction");
150
+ /**
151
+ * Where the path stood: inside the schema bootstrap transaction,
152
+ * waiting for a pooled connection, or mid-admission-transaction. The
153
+ * message narrates only what actually happened to a connection in
154
+ * that phase (RV608): a refusal while WAITING held nothing, so it
155
+ * destroyed nothing.
156
+ */
157
+ readonly phase: "bootstrap" | "acquire" | "transaction";
158
+ constructor(deadlineMs: number, schema: string, phase: "bootstrap" | "acquire" | "transaction");
159
+ }
160
+ /**
161
+ * Thrown by an admission whose booted rule identity no longer matches
162
+ * the schema's (RV608): another deployment rotated the recorded rules
163
+ * fingerprint and generation after this host booted, so admitting under
164
+ * the retired rules would silently split the budget across mismatched
165
+ * bucket keys. The refused host must restart with the current rule set;
166
+ * its outstanding reservations age out with their window (the same
167
+ * bounded residue a crashed process leaves), and the rotation carried
168
+ * current-window consumption conservatively. Like every limiter throw,
169
+ * it lands in the engine's `onLimiterError` policy.
170
+ */
171
+ declare class QuotaGenerationError extends Error {
172
+ /** The schema whose recorded identity moved. */
173
+ readonly schema: string;
174
+ /** What this instance booted with. */
175
+ readonly booted: {
176
+ fingerprint: string;
177
+ generation: number;
178
+ };
179
+ /** What the schema records now (absent fields mean a wiped meta row). */
180
+ readonly recorded: {
181
+ fingerprint: string | undefined;
182
+ generation: number | undefined;
183
+ };
184
+ constructor(schema: string, booted: {
185
+ fingerprint: string;
186
+ generation: number;
187
+ }, recorded: {
188
+ fingerprint: string | undefined;
189
+ generation: number | undefined;
190
+ });
153
191
  }
154
192
  /**
155
193
  * The canonical fingerprint of one rule SET (RV506): sha256 hex over
156
- * the sorted canonical rule keys. Order-insensitive on purpose,
157
- * matching bucket semantics (equal rules land on the same bucket
158
- * regardless of array position), so reordering a config never reads as
159
- * a rules change. Exported so a deployment can precompute the value it
160
- * expects a schema to have recorded.
194
+ * the sorted canonical rule keys (the core's `quotaRuleKey`, the same
195
+ * encoding both store references bucket on). Order-insensitive on
196
+ * purpose, matching bucket semantics (equal rules land on the same
197
+ * bucket regardless of array position), so reordering a config never
198
+ * reads as a rules change. Exported so a deployment can precompute the
199
+ * value it expects a schema to have recorded.
161
200
  */
162
201
  declare function quotaRulesFingerprint(rules: readonly QuotaRule[]): string;
163
202
  interface PostgresQuotaLimiterOptions {
@@ -234,10 +273,24 @@ declare class PostgresQuotaLimiter implements QuotaLimiter {
234
273
  private readonly pool;
235
274
  private readonly schema;
236
275
  private readonly rules;
276
+ /** Matching order for the denial fold: canonical rule-key order
277
+ * (RV608), so permuted identical sets produce byte-identical
278
+ * refusals. Telemetry keeps the declared order. */
279
+ private readonly ordered;
280
+ /** Computed ONCE from the immutable snapshot at construction (RV608):
281
+ * what boot records and every admission re-verifies. */
282
+ private readonly fingerprint;
283
+ /** The schema generation this instance booted at; admissions re-read
284
+ * the schema's and are fenced typed on a mismatch (RV608). */
285
+ private generation;
237
286
  private readonly admissionDeadlineMs;
238
287
  private readonly acceptRulesUpdate;
239
288
  private readonly now;
240
289
  private boot;
290
+ /** The bootstrap transaction's connection while one is in flight, so
291
+ * a deadline can destroy it instead of letting an abandoned bootstrap
292
+ * commit DDL or a rotation late (RV608). */
293
+ private bootClient;
241
294
  constructor(options: PostgresQuotaLimiterOptions);
242
295
  /** `"schema".rulvar_<name>`, always schema-qualified. */
243
296
  private table;
@@ -268,6 +321,16 @@ declare class PostgresQuotaLimiter implements QuotaLimiter {
268
321
  * timeouts).
269
322
  */
270
323
  private withQuotaLock;
324
+ /**
325
+ * The generation fence (RV608), run INSIDE every admission
326
+ * transaction after the lock is taken: the schema's recorded rule
327
+ * identity is re-read and compared to what this instance booted, so a
328
+ * host whose rules were rotated away admits NOTHING under retired
329
+ * bucket keys. The refusal is typed, and the boot memo is cleared so
330
+ * the host's next call re-boots into the honest boot-time refusal
331
+ * naming both fingerprints.
332
+ */
333
+ private assertCurrentIdentity;
271
334
  reserve(request: QuotaReservationRequest): Promise<QuotaDecision>;
272
335
  reconcile(reservationId: string, usage: Usage): Promise<void>;
273
336
  /** Current-window counters per rule, for telemetry and referees. */
@@ -282,4 +345,4 @@ declare class PostgresQuotaLimiter implements QuotaLimiter {
282
345
  private prune;
283
346
  }
284
347
  //#endregion
285
- export { DEFAULT_LEASE_TTL_MS, DEFAULT_POOL_MAX, PostgresQuotaLimiter, type PostgresQuotaLimiterOptions, PostgresStore, type PostgresStoreOptions, type PostgresTranscriptStore, QUOTA_ADMISSION_DEADLINE_MS, QUOTA_LOCK_TIMEOUT_MS, QuotaDeadlineError, quotaRulesFingerprint };
348
+ export { DEFAULT_LEASE_TTL_MS, DEFAULT_POOL_MAX, PostgresQuotaLimiter, type PostgresQuotaLimiterOptions, PostgresStore, type PostgresStoreOptions, type PostgresTranscriptStore, QUOTA_ADMISSION_DEADLINE_MS, QUOTA_LOCK_TIMEOUT_MS, QuotaDeadlineError, QuotaGenerationError, quotaRulesFingerprint };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import pg from "pg";
2
- import { ConfigError, JournalOrderViolation, LeaseHeldError, QUOTA_WINDOW_MS, mergeQuotaDenial, metaMatchesFilter, quotaActualTokens, quotaEstimateTokens, quotaRuleAdmission, quotaRuleMatches, validateQuotaRules } from "@rulvar/core";
2
+ import { ConfigError, JournalOrderViolation, LeaseHeldError, MAX_TIMER_DELAY_MS, QUOTA_WINDOW_MS, mergeQuotaDenial, metaMatchesFilter, quotaActualTokens, quotaEstimateTokens, quotaRuleAdmission, quotaRuleKey, quotaRuleMatches, snapshotQuotaRules } from "@rulvar/core";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  //#region src/store.ts
5
5
  /**
@@ -495,43 +495,72 @@ var QuotaDeadlineError = class extends Error {
495
495
  deadlineMs;
496
496
  /** The schema whose admission missed it. */
497
497
  schema;
498
- /** Where the path stood: acquiring a connection, or mid-transaction. */
498
+ /**
499
+ * Where the path stood: inside the schema bootstrap transaction,
500
+ * waiting for a pooled connection, or mid-admission-transaction. The
501
+ * message narrates only what actually happened to a connection in
502
+ * that phase (RV608): a refusal while WAITING held nothing, so it
503
+ * destroyed nothing.
504
+ */
499
505
  phase;
500
506
  constructor(deadlineMs, schema, phase) {
501
- super(`PostgresQuotaLimiter admission over schema "${schema}" missed its ${String(deadlineMs)}ms deadline ` + (phase === "acquire" ? "while waiting for bootstrap or a pooled connection" : "inside the admission transaction") + "; the in-flight admission was abandoned and its connection destroyed");
507
+ super(`PostgresQuotaLimiter admission over schema "${schema}" missed its ${String(deadlineMs)}ms deadline ` + (phase === "bootstrap" ? "during schema bootstrap; the bootstrap transaction was abandoned and its connection destroyed, so nothing it staged (DDL, fingerprint, generation) was committed" : phase === "acquire" ? "while waiting for a pooled connection; the admission was abandoned before it held one" : "inside the admission transaction; the admission was abandoned and its connection destroyed"));
502
508
  this.name = "QuotaDeadlineError";
503
509
  this.deadlineMs = deadlineMs;
504
510
  this.schema = schema;
505
511
  this.phase = phase;
506
512
  }
507
513
  };
508
- const wallClock = Date.now.bind(globalThis);
509
514
  /**
510
- * The canonical bucket key of one rule: a fixed-field-order JSON of
511
- * its content, identical across hosts for identical rules (and
512
- * identical to the SqliteQuotaLimiter encoding).
515
+ * Thrown by an admission whose booted rule identity no longer matches
516
+ * the schema's (RV608): another deployment rotated the recorded rules
517
+ * fingerprint and generation after this host booted, so admitting under
518
+ * the retired rules would silently split the budget across mismatched
519
+ * bucket keys. The refused host must restart with the current rule set;
520
+ * its outstanding reservations age out with their window (the same
521
+ * bounded residue a crashed process leaves), and the rotation carried
522
+ * current-window consumption conservatively. Like every limiter throw,
523
+ * it lands in the engine's `onLimiterError` policy.
513
524
  */
514
- function ruleKey(rule) {
515
- return JSON.stringify({
516
- provider: rule.provider ?? null,
517
- model: rule.model ?? null,
518
- tenant: rule.tenant ?? null,
519
- requestsPerMinute: rule.requestsPerMinute ?? null,
520
- tokensPerMinute: rule.tokensPerMinute ?? null
521
- });
522
- }
525
+ var QuotaGenerationError = class extends Error {
526
+ /** The schema whose recorded identity moved. */
527
+ schema;
528
+ /** What this instance booted with. */
529
+ booted;
530
+ /** What the schema records now (absent fields mean a wiped meta row). */
531
+ recorded;
532
+ constructor(schema, booted, recorded) {
533
+ super(`PostgresQuotaLimiter admission over schema "${schema}" was fenced: the schema now records rules fingerprint ${recorded.fingerprint ?? "(none)"} at generation ${String(recorded.generation ?? "(none)")}, this instance booted fingerprint ${booted.fingerprint} at generation ${String(booted.generation)}. The rules rotated after this host booted; restart it with the current rule set. Its outstanding reservations age out with their window, and the rotation carried current-window consumption conservatively.`);
534
+ this.name = "QuotaGenerationError";
535
+ this.schema = schema;
536
+ this.booted = booted;
537
+ this.recorded = recorded;
538
+ }
539
+ };
540
+ const wallClock = Date.now.bind(globalThis);
523
541
  /**
524
542
  * The canonical fingerprint of one rule SET (RV506): sha256 hex over
525
- * the sorted canonical rule keys. Order-insensitive on purpose,
526
- * matching bucket semantics (equal rules land on the same bucket
527
- * regardless of array position), so reordering a config never reads as
528
- * a rules change. Exported so a deployment can precompute the value it
529
- * expects a schema to have recorded.
543
+ * the sorted canonical rule keys (the core's `quotaRuleKey`, the same
544
+ * encoding both store references bucket on). Order-insensitive on
545
+ * purpose, matching bucket semantics (equal rules land on the same
546
+ * bucket regardless of array position), so reordering a config never
547
+ * reads as a rules change. Exported so a deployment can precompute the
548
+ * value it expects a schema to have recorded.
530
549
  */
531
550
  function quotaRulesFingerprint(rules) {
532
- const keys = rules.map(ruleKey).sort();
551
+ const keys = rules.map(quotaRuleKey).sort();
533
552
  return createHash("sha256").update(JSON.stringify(keys)).digest("hex");
534
553
  }
554
+ /** The dimension triple of one canonical rule key, the identity the
555
+ * rotation carry maps consumption across cap changes by. */
556
+ function dimensionTriple(key) {
557
+ const parsed = JSON.parse(key);
558
+ return JSON.stringify({
559
+ provider: parsed.provider ?? null,
560
+ model: parsed.model ?? null,
561
+ tenant: parsed.tenant ?? null
562
+ });
563
+ }
535
564
  /**
536
565
  * The multi-host reference implementation of the core QuotaLimiter
537
566
  * SPI: engine processes pointing instances at ONE database and schema
@@ -563,19 +592,38 @@ var PostgresQuotaLimiter = class {
563
592
  pool;
564
593
  schema;
565
594
  rules;
595
+ /** Matching order for the denial fold: canonical rule-key order
596
+ * (RV608), so permuted identical sets produce byte-identical
597
+ * refusals. Telemetry keeps the declared order. */
598
+ ordered;
599
+ /** Computed ONCE from the immutable snapshot at construction (RV608):
600
+ * what boot records and every admission re-verifies. */
601
+ fingerprint;
602
+ /** The schema generation this instance booted at; admissions re-read
603
+ * the schema's and are fenced typed on a mismatch (RV608). */
604
+ generation = 0;
566
605
  admissionDeadlineMs;
567
606
  acceptRulesUpdate;
568
607
  now;
569
608
  boot;
609
+ /** The bootstrap transaction's connection while one is in flight, so
610
+ * a deadline can destroy it instead of letting an abandoned bootstrap
611
+ * commit DDL or a rotation late (RV608). */
612
+ bootClient;
570
613
  constructor(options) {
571
614
  if (typeof options.url !== "string" || options.url === "") throw new ConfigError("PostgresQuotaLimiterOptions.url must be a nonempty connection string");
572
615
  const schema = options.schema ?? "public";
573
616
  if (!IDENTIFIER.test(schema)) throw new ConfigError(`PostgresQuotaLimiterOptions.schema must be a plain SQL identifier; got '${schema}'`);
574
- validateQuotaRules(options.rules, "PostgresQuotaLimiterOptions.rules");
575
617
  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)}`);
576
- if (options.admissionDeadlineMs !== void 0 && (!Number.isInteger(options.admissionDeadlineMs) || options.admissionDeadlineMs <= 2e3)) throw new ConfigError(`PostgresQuotaLimiterOptions.admissionDeadlineMs must be an integer greater than QUOTA_LOCK_TIMEOUT_MS (${String(QUOTA_LOCK_TIMEOUT_MS)}), the lock-wait stage it bounds; got ${String(options.admissionDeadlineMs)}`);
618
+ if (options.admissionDeadlineMs !== void 0 && (!Number.isInteger(options.admissionDeadlineMs) || options.admissionDeadlineMs <= 2e3 || options.admissionDeadlineMs > MAX_TIMER_DELAY_MS)) throw new ConfigError(`PostgresQuotaLimiterOptions.admissionDeadlineMs must be an integer greater than QUOTA_LOCK_TIMEOUT_MS (${String(QUOTA_LOCK_TIMEOUT_MS)}), the lock-wait stage it bounds, and at most ${String(MAX_TIMER_DELAY_MS)} (the Node timer maximum, above which a timer fires after about a millisecond); got ${String(options.admissionDeadlineMs)}`);
619
+ if (options.acceptRulesUpdate !== void 0 && typeof options.acceptRulesUpdate !== "boolean") throw new ConfigError(`PostgresQuotaLimiterOptions.acceptRulesUpdate must be a boolean when given (it authorizes rewriting the schema's recorded rule identity, so truthiness is not enough); got ${JSON.stringify(options.acceptRulesUpdate)}`);
577
620
  this.schema = schema;
578
- this.rules = options.rules;
621
+ this.rules = snapshotQuotaRules(options.rules, "PostgresQuotaLimiterOptions.rules");
622
+ this.ordered = this.rules.map((rule) => ({
623
+ rule,
624
+ key: quotaRuleKey(rule)
625
+ })).sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
626
+ this.fingerprint = quotaRulesFingerprint(this.rules);
579
627
  this.admissionDeadlineMs = options.admissionDeadlineMs ?? 5e3;
580
628
  this.acceptRulesUpdate = options.acceptRulesUpdate ?? false;
581
629
  this.now = options.now ?? wallClock;
@@ -605,10 +653,12 @@ var PostgresQuotaLimiter = class {
605
653
  return this.boot;
606
654
  }
607
655
  async runBootstrap() {
608
- const fingerprint = quotaRulesFingerprint(this.rules);
609
656
  const client = await this.pool.connect();
657
+ this.bootClient = client;
658
+ let destroyed = false;
610
659
  try {
611
660
  await client.query("BEGIN");
661
+ await client.query(`SET LOCAL lock_timeout = '${String(QUOTA_LOCK_TIMEOUT_MS)}ms'`);
612
662
  await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, $2))", [`rulvar-quota-boot:${this.schema}`, LOCK_SEED]);
613
663
  if (this.schema !== "public") await client.query(`CREATE SCHEMA IF NOT EXISTS "${this.schema}"`);
614
664
  await client.query(`
@@ -630,20 +680,74 @@ var PostgresQuotaLimiter = class {
630
680
  value TEXT
631
681
  );
632
682
  `);
633
- const recorded = (await client.query(`SELECT value FROM ${this.table("quota_meta")} WHERE key = 'rules_fingerprint'`)).rows[0]?.value;
634
- if (recorded === void 0 || this.acceptRulesUpdate) await client.query(`INSERT INTO ${this.table("quota_meta")} (key, value) VALUES ('rules_fingerprint', $1)
635
- ON CONFLICT (key) DO UPDATE SET value = excluded.value`, [fingerprint]);
636
- else if (recorded !== fingerprint) throw new ConfigError(`PostgresQuotaLimiter rules mismatch over schema "${this.schema}": the schema records rules fingerprint ${recorded}, this instance computes ${fingerprint}. Every coordinating process must configure the identical rule set (buckets key on rule content, so a drifted host would silently split the budget). To rotate the rules, boot the new deployment with acceptRulesUpdate: true, then remove the flag.`, { data: {
683
+ const meta = new Map((await client.query(`SELECT key, value FROM ${this.table("quota_meta")}
684
+ WHERE key IN ('rules_fingerprint', 'rules_generation')`)).rows.map((row) => [row.key, row.value]));
685
+ const recorded = meta.get("rules_fingerprint");
686
+ const recordedGeneration = meta.get("rules_generation");
687
+ const writeIdentity = async (generation) => {
688
+ await client.query(`INSERT INTO ${this.table("quota_meta")} (key, value)
689
+ VALUES ('rules_fingerprint', $1), ('rules_generation', $2)
690
+ ON CONFLICT (key) DO UPDATE SET value = excluded.value`, [this.fingerprint, String(generation)]);
691
+ };
692
+ if (recorded === void 0) {
693
+ this.generation = 1;
694
+ await writeIdentity(this.generation);
695
+ } else if (recorded === this.fingerprint) {
696
+ this.generation = recordedGeneration === void 0 ? 1 : Number(recordedGeneration);
697
+ if (recordedGeneration === void 0) await writeIdentity(this.generation);
698
+ } else if (this.acceptRulesUpdate) {
699
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, $2))", [`${this.schema}:rulvar-quota`, LOCK_SEED]);
700
+ const at = this.now();
701
+ const windowStart = at - at % QUOTA_WINDOW_MS;
702
+ const oldBuckets = (await client.query(`SELECT rule_key, requests::int8 AS requests, tokens::int8 AS tokens
703
+ FROM ${this.table("quota_buckets")} WHERE window_start = $1`, [windowStart])).rows;
704
+ const byTriple = /* @__PURE__ */ new Map();
705
+ for (const row of oldBuckets) {
706
+ const triple = dimensionTriple(row.rule_key);
707
+ const prior = byTriple.get(triple) ?? {
708
+ requests: 0,
709
+ tokens: 0
710
+ };
711
+ byTriple.set(triple, {
712
+ requests: Math.max(prior.requests, Number(row.requests)),
713
+ tokens: Math.max(prior.tokens, Number(row.tokens))
714
+ });
715
+ }
716
+ for (const { rule, key } of this.ordered) {
717
+ const carried = byTriple.get(JSON.stringify({
718
+ provider: rule.provider ?? null,
719
+ model: rule.model ?? null,
720
+ tenant: rule.tenant ?? null
721
+ }));
722
+ if (carried === void 0 || carried.requests === 0 && carried.tokens === 0) continue;
723
+ await client.query(`INSERT INTO ${this.table("quota_buckets")} AS b
724
+ (rule_key, window_start, requests, tokens) VALUES ($1, $2, $3, $4)
725
+ ON CONFLICT (rule_key, window_start) DO UPDATE SET
726
+ requests = GREATEST(b.requests, excluded.requests),
727
+ tokens = GREATEST(b.tokens, excluded.tokens)`, [
728
+ key,
729
+ windowStart,
730
+ carried.requests,
731
+ carried.tokens
732
+ ]);
733
+ }
734
+ this.generation = (recordedGeneration === void 0 ? 1 : Number(recordedGeneration)) + 1;
735
+ await writeIdentity(this.generation);
736
+ } else throw new ConfigError(`PostgresQuotaLimiter rules mismatch over schema "${this.schema}": the schema records rules fingerprint ${recorded}, this instance computes ${this.fingerprint}. Every coordinating process must configure the identical rule set (buckets key on rule content, so a drifted host would silently split the budget). To rotate the rules, boot the new deployment with acceptRulesUpdate: true, then remove the flag.`, { data: {
637
737
  schema: this.schema,
638
738
  recorded,
639
- computed: fingerprint
739
+ computed: this.fingerprint
640
740
  } });
641
741
  await client.query("COMMIT");
642
742
  } catch (thrown) {
643
- await client.query("ROLLBACK").catch(() => void 0);
743
+ destroyed = this.bootClient === void 0;
744
+ if (!destroyed) await client.query("ROLLBACK").catch(() => void 0);
644
745
  throw thrown;
645
746
  } finally {
646
- client.release();
747
+ if (this.bootClient !== void 0) {
748
+ this.bootClient = void 0;
749
+ if (!destroyed) client.release();
750
+ }
647
751
  }
648
752
  }
649
753
  /**
@@ -667,7 +771,7 @@ var PostgresQuotaLimiter = class {
667
771
  let client;
668
772
  let expired = false;
669
773
  let timer;
670
- const refusal = () => new QuotaDeadlineError(this.admissionDeadlineMs, this.schema, client === void 0 ? "acquire" : "transaction");
774
+ const refusal = () => new QuotaDeadlineError(this.admissionDeadlineMs, this.schema, client !== void 0 ? "transaction" : this.bootClient !== void 0 ? "bootstrap" : "acquire");
671
775
  const work = (async () => {
672
776
  await this.booted();
673
777
  if (expired) throw refusal();
@@ -698,6 +802,8 @@ var PostgresQuotaLimiter = class {
698
802
  expired = true;
699
803
  client?.release(reason);
700
804
  client = void 0;
805
+ this.bootClient?.release(reason);
806
+ this.bootClient = void 0;
701
807
  reject(reason);
702
808
  }, this.admissionDeadlineMs);
703
809
  });
@@ -707,17 +813,43 @@ var PostgresQuotaLimiter = class {
707
813
  clearTimeout(timer);
708
814
  }
709
815
  }
816
+ /**
817
+ * The generation fence (RV608), run INSIDE every admission
818
+ * transaction after the lock is taken: the schema's recorded rule
819
+ * identity is re-read and compared to what this instance booted, so a
820
+ * host whose rules were rotated away admits NOTHING under retired
821
+ * bucket keys. The refusal is typed, and the boot memo is cleared so
822
+ * the host's next call re-boots into the honest boot-time refusal
823
+ * naming both fingerprints.
824
+ */
825
+ async assertCurrentIdentity(client) {
826
+ const meta = new Map((await client.query(`SELECT key, value FROM ${this.table("quota_meta")}
827
+ WHERE key IN ('rules_fingerprint', 'rules_generation')`)).rows.map((row) => [row.key, row.value]));
828
+ const fingerprint = meta.get("rules_fingerprint");
829
+ const generationRaw = meta.get("rules_generation");
830
+ const generation = generationRaw === void 0 ? void 0 : Number(generationRaw);
831
+ if (fingerprint !== this.fingerprint || generation !== this.generation) {
832
+ this.boot = void 0;
833
+ throw new QuotaGenerationError(this.schema, {
834
+ fingerprint: this.fingerprint,
835
+ generation: this.generation
836
+ }, {
837
+ fingerprint,
838
+ generation
839
+ });
840
+ }
841
+ }
710
842
  reserve(request) {
711
843
  const at = this.now();
712
844
  const windowStart = at - at % QUOTA_WINDOW_MS;
713
845
  const estimateTokens = quotaEstimateTokens(request);
714
846
  return this.withQuotaLock(async (client) => {
847
+ await this.assertCurrentIdentity(client);
715
848
  await this.prune(client, windowStart);
716
849
  const matched = [];
717
850
  let denial;
718
- for (const rule of this.rules) {
851
+ for (const { rule, key } of this.ordered) {
719
852
  if (!quotaRuleMatches(rule, request)) continue;
720
- const key = ruleKey(rule);
721
853
  matched.push(key);
722
854
  const row = (await client.query(`SELECT requests::int8 AS requests, tokens::int8 AS tokens
723
855
  FROM ${this.table("quota_buckets")} WHERE rule_key = $1 AND window_start = $2`, [key, windowStart])).rows[0];
@@ -764,6 +896,7 @@ var PostgresQuotaLimiter = class {
764
896
  const at = this.now();
765
897
  const windowStart = at - at % QUOTA_WINDOW_MS;
766
898
  return this.withQuotaLock(async (client) => {
899
+ await this.assertCurrentIdentity(client);
767
900
  const row = (await client.query(`SELECT window_start::int8 AS window_start, estimate_tokens::int8 AS estimate_tokens,
768
901
  rule_keys
769
902
  FROM ${this.table("quota_reservations")} WHERE id = $1`, [reservationId])).rows[0];
@@ -788,7 +921,7 @@ var PostgresQuotaLimiter = class {
788
921
  const out = [];
789
922
  for (const rule of this.rules) {
790
923
  const row = (await this.pool.query(`SELECT requests::int8 AS requests, tokens::int8 AS tokens
791
- FROM ${this.table("quota_buckets")} WHERE rule_key = $1 AND window_start = $2`, [ruleKey(rule), windowStart])).rows[0];
924
+ FROM ${this.table("quota_buckets")} WHERE rule_key = $1 AND window_start = $2`, [quotaRuleKey(rule), windowStart])).rows[0];
792
925
  out.push({
793
926
  rule,
794
927
  windowStart,
@@ -809,4 +942,4 @@ var PostgresQuotaLimiter = class {
809
942
  }
810
943
  };
811
944
  //#endregion
812
- export { DEFAULT_LEASE_TTL_MS, DEFAULT_POOL_MAX, PostgresQuotaLimiter, PostgresStore, QUOTA_ADMISSION_DEADLINE_MS, QUOTA_LOCK_TIMEOUT_MS, QuotaDeadlineError, quotaRulesFingerprint };
945
+ export { DEFAULT_LEASE_TTL_MS, DEFAULT_POOL_MAX, PostgresQuotaLimiter, PostgresStore, QUOTA_ADMISSION_DEADLINE_MS, QUOTA_LOCK_TIMEOUT_MS, QuotaDeadlineError, QuotaGenerationError, quotaRulesFingerprint };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/store-postgres",
3
- "version": "1.104.0",
3
+ "version": "1.106.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.104.0"
26
+ "@rulvar/core": "1.106.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.104.0"
33
+ "@rulvar/store-conformance": "1.106.0"
34
34
  },
35
35
  "repository": {
36
36
  "type": "git",