@voltro/plugin-ratelimit 0.81.0 → 0.83.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,9 +1,12 @@
1
+ import { ColumnBuilder } from '@voltro/database';
2
+ import { DataStore } from '@voltro/database';
1
3
  import { Effect } from 'effect';
4
+ import { FieldDefinitions } from '@voltro/database';
2
5
  import { RespClient } from '@voltro/kv/connection';
3
6
  import { RpcKind } from '@voltro/protocol';
4
7
  import { Schema } from 'effect';
5
- import { SqlClient } from '@effect/sql';
6
8
  import { Subject } from '@voltro/protocol';
9
+ import { Table } from '@voltro/database';
7
10
  import { VoltroPlugin } from '@voltro/protocol';
8
11
 
9
12
  export declare type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
@@ -79,13 +82,15 @@ export declare interface MemoryStoreOptions {
79
82
  * bad window is a config error that should surface loudly at boot. */
80
83
  export declare const parseWindow: (spec: WindowSpec) => number;
81
84
 
85
+ export declare const postgresStore: (options?: PostgresStoreOptions) => RateLimitStore;
86
+
82
87
  export declare interface PostgresStoreOptions {
83
- /** Table name. Default `_voltro_ratelimit`. */
84
- readonly table?: string;
85
88
  /** Drop rows untouched for longer than this (ms) on sweep. Default 1h. */
86
89
  readonly staleAfterMs?: number;
87
90
  }
88
91
 
92
+ export declare const RATELIMIT_TABLE = "_voltro_ratelimit";
93
+
89
94
  /** The context handed to every dynamic option + the `resolve` hook.
90
95
  * Derived from the framework's RpcInterceptorContext. */
91
96
  export declare interface RateLimitContext {
@@ -261,13 +266,22 @@ export declare interface RateLimitStore {
261
266
  readonly activate?: (env: NodeJS.ProcessEnv) => Promise<void>;
262
267
  /** Optional teardown at plugin deactivate (close pools). */
263
268
  readonly deactivate?: () => Promise<void>;
264
- /** Optional bind of the framework's already-open `SqlClient` (from the
265
- * plugin's `bindDataStore(store, ctx)` → `ctx.sql`). A SQL-backed store
266
- * runs through THIS client instead of standing up its own pool; creates
267
- * its bookkeeping table here. Not present on memory / redis stores. */
268
- readonly bindSql?: (sql: SqlClient.SqlClient) => Promise<void>;
269
+ /** Optional bind of the app's `DataStore` (the plugin's `bindDataStore`).
270
+ * A store kept in the app's database reads and writes through it — its
271
+ * table is declared through `extendSchema`, never created here. Not
272
+ * present on memory / redis stores. */
273
+ readonly bind?: (store: DataStore) => Promise<void>;
269
274
  }
270
275
 
276
+ /** One row per rate-limit key. `id` + a unique `key` rather than `key` as the
277
+ * primary key: every dialect store addresses a row by `id`. */
278
+ export declare const rateLimitTable: Table<"_voltro_ratelimit", FieldDefinitions<{
279
+ readonly id: ColumnBuilder<number, "id", boolean>;
280
+ readonly key: ColumnBuilder<string, "text", boolean>;
281
+ readonly state: ColumnBuilder<BucketState | null, "json", boolean>;
282
+ readonly touchedAt: ColumnBuilder<Date, "timestamp", boolean>;
283
+ }>, false, never>;
284
+
271
285
  export declare const redisStore: (options?: RedisStoreOptions) => RateLimitStore;
272
286
 
273
287
  export declare interface RedisStoreOptions {
package/dist/index.js CHANGED
@@ -1,23 +1,23 @@
1
1
  import { RateLimited as e } from "./errors.js";
2
- import { n as t, t as n } from "./window-lyQ6LXhj.js";
3
- import { redisStore as r } from "./redisStore.js";
4
- import { Effect as i } from "effect";
5
- import { createLogger as a } from "@voltro/logger";
6
- import { replicaEvidence as o } from "@voltro/protocol/identity";
7
- import { definePlugin as s, pluginInstanceName as c } from "@voltro/protocol";
2
+ import { a as t, i as n, n as r, r as i, t as a } from "./postgresStore-B5gTBWHd.js";
3
+ import { redisStore as o } from "./redisStore.js";
4
+ import { Effect as s } from "effect";
5
+ import { createLogger as c } from "@voltro/logger";
6
+ import { replicaEvidence as l } from "@voltro/protocol/identity";
7
+ import { definePlugin as u, pluginInstanceName as d } from "@voltro/protocol";
8
8
  //#region src/inspect.ts
9
- var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "function" ? "dynamic" : e, d = (e, n) => ({
9
+ var f = (e) => typeof e == "function" ? "dynamic" : e, p = (e) => typeof e == "function" ? "dynamic" : e, m = (e, n) => ({
10
10
  id: n === null ? "default" : `r${n}`,
11
11
  match: "match" in e ? e.match instanceof RegExp ? String(e.match) : Array.isArray(e.match) ? e.match.join(", ") : e.match ?? "*" : "*",
12
12
  kinds: e.kind === void 0 ? [] : typeof e.kind == "string" ? [e.kind] : e.kind,
13
- limit: l(e.limit),
13
+ limit: f(e.limit),
14
14
  windowMs: typeof e.window == "function" ? "dynamic" : t(e.window),
15
15
  algorithm: e.algorithm ?? "sliding-window",
16
- burst: e.burst === void 0 ? l(e.limit) : l(e.burst),
17
- by: Array.isArray(e.by) ? e.by.map(u) : [u(e.by ?? "subject")],
16
+ burst: e.burst === void 0 ? f(e.limit) : f(e.burst),
17
+ by: Array.isArray(e.by) ? e.by.map(p) : [p(e.by ?? "subject")],
18
18
  scope: e.scope ?? "tag",
19
19
  tenantOverrides: Object.keys(e.tenants ?? {}).length
20
- }), f = () => ({
20
+ }), h = () => ({
21
21
  allowed: 0,
22
22
  rejected: 0,
23
23
  bypassed: 0,
@@ -25,11 +25,11 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
25
25
  storeErrors: 0,
26
26
  lastRejectedAt: null,
27
27
  lastStoreErrorAt: null
28
- }), p = (e, n) => {
28
+ }), g = (e, n) => {
29
29
  let r = Date.now(), i = {
30
- rpc: f(),
31
- http: f()
32
- }, a = n.bindSql ? "waiting" : "not-required";
30
+ rpc: h(),
31
+ http: h()
32
+ }, a = n.bind ? "waiting" : "not-required";
33
33
  return {
34
34
  binding: (e) => {
35
35
  a = e;
@@ -59,8 +59,8 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
59
59
  sqlBinding: a,
60
60
  trackedBuckets: o
61
61
  },
62
- rules: (e.rules ?? []).map((e, t) => d(e, t)),
63
- defaultRule: e.default ? d(e.default, null) : null,
62
+ rules: (e.rules ?? []).map((e, t) => m(e, t)),
63
+ defaultRule: e.default ? m(e.default, null) : null,
64
64
  http: e.http ? {
65
65
  limit: e.http.limit,
66
66
  windowMs: t(e.http.window),
@@ -73,51 +73,34 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
73
73
  };
74
74
  }
75
75
  };
76
- }, m = (e = {}) => {
77
- let t = e.maxKeys ?? 1e5, r = e.staleAfterMs ?? 36e5, a = /* @__PURE__ */ new Map(), o = (e) => {
78
- if (a.size <= t) return;
79
- let n = a.size - t, r = 0;
80
- for (let t of a.keys()) {
76
+ }, _ = (e = {}) => {
77
+ let t = e.maxKeys ?? 1e5, r = e.staleAfterMs ?? 36e5, i = /* @__PURE__ */ new Map(), a = (e) => {
78
+ if (i.size <= t) return;
79
+ let n = i.size - t, r = 0;
80
+ for (let t of i.keys()) {
81
81
  if (r >= n) break;
82
- t !== e && (a.delete(t), r += 1);
82
+ t !== e && (i.delete(t), r += 1);
83
83
  }
84
84
  };
85
85
  return {
86
- consume: (e, t, r) => i.sync(() => {
87
- let i = a.get(e), { state: s, decision: c } = n(i?.state, t, r);
88
- return a.set(e, {
86
+ consume: (e, t, r) => s.sync(() => {
87
+ let o = i.get(e), { state: s, decision: c } = n(o?.state, t, r);
88
+ return i.set(e, {
89
89
  state: s,
90
90
  touched: r
91
- }), o(e), c;
91
+ }), a(e), c;
92
92
  }),
93
93
  sweep: (e) => {
94
94
  let t = e - r;
95
- for (let [e, n] of a) n.touched < t && a.delete(e);
95
+ for (let [e, n] of i) n.touched < t && i.delete(e);
96
96
  },
97
- size: () => a.size
97
+ size: () => i.size
98
98
  };
99
- }, h = (e, t) => ({
100
- allowed: !0,
101
- failOpenReason: "unbound",
102
- remaining: e.limit,
103
- resetAtMs: t + e.windowMs,
104
- retryAfterMs: 0
105
- }), g = (e = {}) => {
106
- let t = null;
107
- return {
108
- consume: (e, n, r) => t ? t.consume(e, n, r) : i.succeed(h(n, r)),
109
- sweep: (e) => t?.sweep(e),
110
- size: () => t ? t.size() : -1,
111
- bindSql: async (n) => {
112
- let { postgresStore: r } = await import("./postgresStore.js"), i = r(e);
113
- await i.bindSql(n), t = i;
114
- }
115
- };
116
- }, _ = (e, t) => typeof e == "function" ? e(t) : e, v = /* @__PURE__ */ new WeakMap(), y = (e) => {
99
+ }, v = (e, t) => typeof e == "function" ? e(t) : e, y = /* @__PURE__ */ new WeakMap(), b = (e) => {
117
100
  if (!e.global && !e.sticky) return e;
118
- let t = v.get(e);
119
- return t === void 0 && (t = new RegExp(e.source, e.flags.replace(/[gy]/g, "")), v.set(e, t)), t;
120
- }, b = (e, t) => e === void 0 ? !0 : typeof e == "string" ? e === t : Array.isArray(e) ? e.includes(t) : y(e).test(t), x = (e, t) => e === void 0 ? !0 : Array.isArray(e) ? e.includes(t) : e === t, S = (e, t) => {
101
+ let t = y.get(e);
102
+ return t === void 0 && (t = new RegExp(e.source, e.flags.replace(/[gy]/g, "")), y.set(e, t)), t;
103
+ }, x = (e, t) => e === void 0 ? !0 : typeof e == "string" ? e === t : Array.isArray(e) ? e.includes(t) : b(e).test(t), S = (e, t) => e === void 0 ? !0 : Array.isArray(e) ? e.includes(t) : e === t, C = (e, t) => {
121
104
  let n = t === null ? void 0 : e.tenants?.[t];
122
105
  return {
123
106
  limit: n?.limit ?? e.limit,
@@ -128,27 +111,27 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
128
111
  scope: e.scope ?? "tag",
129
112
  disabled: n?.disabled ?? !1
130
113
  };
131
- }, C = (e, t, n) => {
114
+ }, w = (e, t, n) => {
132
115
  for (let t = 0; t < e.length; t++) {
133
116
  let r = e[t];
134
- if (b(r.match, n.tag) && x(r.kind, n.kind)) return {
135
- effective: S(r, n.tenantId),
117
+ if (x(r.match, n.tag) && S(r.kind, n.kind)) return {
118
+ effective: C(r, n.tenantId),
136
119
  ruleId: `r${t}`
137
120
  };
138
121
  }
139
- if (t && x(t.kind, n.kind)) return {
140
- effective: S(t, n.tenantId),
122
+ if (t && S(t.kind, n.kind)) return {
123
+ effective: C(t, n.tenantId),
141
124
  ruleId: "default"
142
125
  };
143
- }, w = (e, n) => {
144
- let r = _(e.limit, n);
126
+ }, T = (e, n) => {
127
+ let r = v(e.limit, n);
145
128
  return {
146
129
  limit: r,
147
- windowMs: t(_(e.window, n)),
130
+ windowMs: t(v(e.window, n)),
148
131
  algorithm: e.algorithm ?? "sliding-window",
149
- burst: e.burst === void 0 ? r : _(e.burst, n)
132
+ burst: e.burst === void 0 ? r : v(e.burst, n)
150
133
  };
151
- }, T = (e) => {
134
+ }, E = (e) => {
152
135
  let n = e.limit;
153
136
  return {
154
137
  limit: n,
@@ -156,12 +139,12 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
156
139
  algorithm: e.algorithm ?? "sliding-window",
157
140
  burst: e.burst ?? n
158
141
  };
159
- }, E = (e) => ({
142
+ }, D = (e) => ({
160
143
  limit: e.limit,
161
144
  window: e.windowMs,
162
145
  algorithm: e.algorithm,
163
146
  burst: e.burst
164
- }), D = (e, t) => {
147
+ }), O = (e, t) => {
165
148
  if (typeof e == "function") return e(t);
166
149
  switch (e) {
167
150
  case "subject": return `s:${t.tenantId ?? "∅"}:${t.subject.id ?? "anon"}`;
@@ -170,10 +153,10 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
170
153
  case "global": return "g";
171
154
  case "ip": return `i:${t.remoteAddr ?? "unknown"}`;
172
155
  }
173
- }, O = (e, t) => {
156
+ }, k = (e, t) => {
174
157
  let { effective: n, ruleId: r } = e, i = n.scope === "rule" ? `rule#${r}` : `tag:${t.tag}`, a = n.by;
175
- return `${i}|${Array.isArray(a) ? a.map((e) => D(e, t)).join("|") : D(a, t)}`;
176
- }, k = (e) => ({
158
+ return `${i}|${Array.isArray(a) ? a.map((e) => O(e, t)).join("|") : O(a, t)}`;
159
+ }, A = (e) => ({
177
160
  tag: e.tag,
178
161
  kind: e.kind,
179
162
  subject: e.subject,
@@ -182,7 +165,7 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
182
165
  ...e.surface === void 0 ? {} : { surface: e.surface },
183
166
  ...e.remoteAddr === void 0 ? {} : { remoteAddr: e.remoteAddr },
184
167
  ...e.surface === "rest" && e.publicApi?.rateLimit !== void 0 ? { publicApiRateLimit: e.publicApi.rateLimit } : {}
185
- }), A = (e) => e.publicApiRateLimit === void 0 ? void 0 : {
168
+ }), j = (e) => e.publicApiRateLimit === void 0 ? void 0 : {
186
169
  ruleId: "publicApi",
187
170
  effective: {
188
171
  limit: e.publicApiRateLimit.limit,
@@ -193,7 +176,7 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
193
176
  scope: "tag",
194
177
  disabled: !1
195
178
  }
196
- }, j = (e) => {
179
+ }, M = (e) => {
197
180
  let t = e, n = Date.now();
198
181
  return {
199
182
  "retry-after": String(Math.max(1, Math.ceil(t.retryAfterMs / 1e3))),
@@ -201,11 +184,11 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
201
184
  "ratelimit-remaining": "0",
202
185
  "ratelimit-reset": String(Math.max(0, Math.ceil((t.resetAtMs - n) / 1e3)))
203
186
  };
204
- }, M = a({ scope: "voltro:ratelimit" }), N = (n = {}) => {
205
- let a = n.rules ?? [], l = n.store === void 0 || n.store === "memory" ? m() : n.store === "postgres" ? g() : n.store === "redis" ? r() : n.store, u = p(n, l), d = (e) => {
187
+ }, N = c({ scope: "voltro:ratelimit" }), P = (n = {}) => {
188
+ let a = n.rules ?? [], c = n.store === void 0 || n.store === "memory" ? _() : n.store === "postgres" ? r() : n.store === "redis" ? o() : n.store, f = g(n, c), p = (e) => {
206
189
  let t = n.enabled;
207
190
  return t === void 0 ? !0 : typeof t == "function" ? t(e) : t;
208
- }, f = (e, t, r, i) => {
191
+ }, m = (e, t, r, i) => {
209
192
  if (!n.onLimited) return;
210
193
  let a = {
211
194
  tag: e.tag,
@@ -221,87 +204,87 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
221
204
  } catch {}
222
205
  }, h = (e, t) => {
223
206
  let r = n.resolve;
224
- return r ? i.suspend(() => {
207
+ return r ? s.suspend(() => {
225
208
  let n = r(e, t);
226
- return (i.isEffect(n) ? n : n instanceof Promise ? i.promise(() => n) : i.succeed(n)).pipe(i.map((e) => e === "exempt" ? "exempt" : T(e === void 0 ? t : e)));
227
- }) : i.succeed(T(t));
228
- }, _ = (t, r) => i.suspend(() => {
229
- let o = k(r);
230
- if (!d(o)) return u.bypass(), t;
231
- let s = A(o) ?? C(a, n.default, o);
232
- if (!s || s.effective.disabled) return u.bypass(), t;
233
- let c = E(w(s.effective, o));
234
- return h(o, c).pipe(i.flatMap((n) => {
235
- if (n === "exempt") return u.bypass(), t;
236
- let r = O(s, o);
237
- return l.consume(r, n, Date.now()).pipe(i.catchAllDefect((e) => i.as(i.logWarning(`[ratelimit] store error — failing open for ${o.tag}: ${String(e)}`), {
209
+ return (s.isEffect(n) ? n : n instanceof Promise ? s.promise(() => n) : s.succeed(n)).pipe(s.map((e) => e === "exempt" ? "exempt" : E(e === void 0 ? t : e)));
210
+ }) : s.succeed(E(t));
211
+ }, v = (t, r) => s.suspend(() => {
212
+ let i = A(r);
213
+ if (!p(i)) return f.bypass(), t;
214
+ let o = j(i) ?? w(a, n.default, i);
215
+ if (!o || o.effective.disabled) return f.bypass(), t;
216
+ let l = D(T(o.effective, i));
217
+ return h(i, l).pipe(s.flatMap((n) => {
218
+ if (n === "exempt") return f.bypass(), t;
219
+ let r = k(o, i);
220
+ return c.consume(r, n, Date.now()).pipe(s.catchAllDefect((e) => s.as(s.logWarning(`[ratelimit] store error — failing open for ${i.tag}: ${String(e)}`), {
238
221
  allowed: !0,
239
222
  failOpenReason: "store-error",
240
223
  remaining: n.limit,
241
224
  resetAtMs: Date.now(),
242
225
  retryAfterMs: 0
243
- })), i.tap((e) => i.sync(() => u.record("rpc", e))), i.flatMap((a) => a.allowed ? t : i.zipRight(i.sync(() => f(o, r, n, a)), i.fail(new e({
244
- tag: o.tag,
226
+ })), s.tap((e) => s.sync(() => f.record("rpc", e))), s.flatMap((a) => a.allowed ? t : s.zipRight(s.sync(() => m(i, r, n, a)), s.fail(new e({
227
+ tag: i.tag,
245
228
  limit: n.limit,
246
229
  windowMs: n.windowMs,
247
230
  retryAfterMs: a.retryAfterMs,
248
231
  resetAtMs: a.resetAtMs
249
232
  })))));
250
233
  }));
251
- }), v = n.http ? async (e, r) => {
252
- let a = n.http, o = {
253
- limit: a.limit,
254
- windowMs: t(a.window),
255
- algorithm: a.algorithm ?? "fixed-window",
256
- burst: a.limit
257
- }, s = r.remoteAddr ?? "unknown", c = Date.now(), d;
234
+ }), y = n.http ? async (e, r) => {
235
+ let i = n.http, a = {
236
+ limit: i.limit,
237
+ windowMs: t(i.window),
238
+ algorithm: i.algorithm ?? "fixed-window",
239
+ burst: i.limit
240
+ }, o = r.remoteAddr ?? "unknown", l = Date.now(), u;
258
241
  try {
259
- d = await i.runPromise(l.consume(`http:ip:${s}`, o, c));
242
+ u = await s.runPromise(c.consume(`http:ip:${o}`, a, l));
260
243
  } catch (t) {
261
- return u.record("http", {
244
+ return f.record("http", {
262
245
  allowed: !0,
263
246
  failOpenReason: "store-error",
264
247
  remaining: 0,
265
- resetAtMs: c,
248
+ resetAtMs: l,
266
249
  retryAfterMs: 0
267
- }), M.warn("http shield store failure — request ALLOWED without a limit check", { cause: String(t) }), e();
250
+ }), N.warn("http shield store failure — request ALLOWED without a limit check", { cause: String(t) }), e();
268
251
  }
269
- u.record("http", d);
270
- let f = String(Math.max(0, Math.ceil((d.resetAtMs - c) / 1e3)));
271
- return d.allowed ? e() : {
252
+ f.record("http", u);
253
+ let d = String(Math.max(0, Math.ceil((u.resetAtMs - l) / 1e3)));
254
+ return u.allowed ? e() : {
272
255
  status: 429,
273
256
  body: "Too Many Requests",
274
257
  headers: {
275
258
  "content-type": "text/plain; charset=utf-8",
276
- "retry-after": String(Math.max(1, Math.ceil(d.retryAfterMs / 1e3))),
277
- "ratelimit-limit": String(o.limit),
278
- "ratelimit-remaining": String(Math.max(0, d.remaining)),
279
- "ratelimit-reset": f
259
+ "retry-after": String(Math.max(1, Math.ceil(u.retryAfterMs / 1e3))),
260
+ "ratelimit-limit": String(a.limit),
261
+ "ratelimit-remaining": String(Math.max(0, u.remaining)),
262
+ "ratelimit-reset": d
280
263
  }
281
264
  };
282
- } : void 0, y = [
265
+ } : void 0, b = [
283
266
  "inspect:read",
284
267
  "rpc:intercept:mutation",
285
268
  "rpc:intercept:query",
286
269
  "rpc:intercept:action",
287
270
  "rpc:intercept:workflow",
288
271
  ...n.http ? ["http:intercept"] : []
289
- ], b, x;
290
- return s({
291
- name: c({
272
+ ], x, S;
273
+ return u({
274
+ name: d({
292
275
  base: "@voltro/plugin-ratelimit",
293
276
  instance: n.name
294
277
  }),
295
278
  description: "Per-endpoint / per-subject / per-tenant rate limiting via the rpc interceptors. Configurable rules, three algorithms, static + dynamic per-tenant overrides.",
296
- permissions: y,
279
+ permissions: b,
297
280
  inspectEndpoints: [{
298
281
  method: "GET",
299
282
  path: "/state",
300
283
  scope: "process",
301
284
  description: "Declared rules and process-local limiting evidence, without bucket identities.",
302
- handler: () => i.sync(() => ({
285
+ handler: () => s.sync(() => ({
303
286
  kind: "json",
304
- data: u.snapshot()
287
+ data: f.snapshot()
305
288
  }))
306
289
  }],
307
290
  declaredEnv: [
@@ -336,7 +319,7 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
336
319
  description: "Fallback Upstash REST token for the http redis driver."
337
320
  }
338
321
  ],
339
- ...v ? { onHttpRequest: v } : {},
322
+ ...y ? { onHttpRequest: y } : {},
340
323
  errorSchemas: [{
341
324
  schema: e,
342
325
  import: {
@@ -345,34 +328,34 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
345
328
  },
346
329
  rest: {
347
330
  status: 429,
348
- headers: j
331
+ headers: M
349
332
  }
350
333
  }],
351
- interceptMutation: _,
352
- interceptQuery: _,
353
- interceptAction: _,
354
- interceptWorkflow: _,
355
- bindDataStore: (e, t) => {
356
- let n = t?.sql;
357
- n && l.bindSql && (u.binding("binding"), l.bindSql(n).then(() => {
358
- x = void 0, u.binding("bound");
334
+ interceptMutation: v,
335
+ interceptQuery: v,
336
+ interceptAction: v,
337
+ interceptWorkflow: v,
338
+ ...n.store === "postgres" ? { extendSchema: { tables: [i] } } : {},
339
+ bindDataStore: (e) => {
340
+ c.bind && (f.binding("binding"), c.bind(e).then(() => {
341
+ S = void 0, f.binding("bound");
359
342
  }).catch((e) => {
360
- u.binding("failed"), x = e;
343
+ f.binding("failed"), S = e;
361
344
  }));
362
345
  },
363
- onActivate: (e) => i.gen(function* () {
364
- l.activate && (yield* i.promise(() => l.activate(e.env))), b = setInterval(() => l.sweep(Date.now()), 6e4), b.unref?.(), x !== void 0 && e.logger.warn("rate-limit: SQL store bind failed — limiter runs fail-open", { error: x instanceof Error ? x.message : String(x) });
365
- let t = n.store === void 0 || n.store === "memory" ? o(e.env) : null;
346
+ onActivate: (e) => s.gen(function* () {
347
+ c.activate && (yield* s.promise(() => c.activate(e.env))), x = setInterval(() => c.sweep(Date.now()), 6e4), x.unref?.(), S !== void 0 && e.logger.warn("rate-limit: SQL store bind failed — limiter runs fail-open", { error: S instanceof Error ? S.message : String(S) });
348
+ let t = n.store === void 0 || n.store === "memory" ? l(e.env) : null;
366
349
  t !== null && e.logger.warn(`rate-limit: the counter store is PROCESS-LOCAL (the default), but ${t}, which means several replicas. Each pod counts on its own, so every limit is effectively multiplied by the number of replicas. Set \`store: 'redis'\` or \`store: 'postgres'\` to share the counters.`), e.logger.info("rate-limit active", {
367
350
  rules: a.length,
368
351
  hasDefault: n.default !== void 0,
369
352
  store: typeof n.store == "string" ? n.store : n.store === void 0 ? "memory" : "custom"
370
353
  });
371
354
  }),
372
- onDeactivate: () => i.gen(function* () {
373
- b && clearInterval(b), l.deactivate && (yield* i.promise(() => l.deactivate()));
355
+ onDeactivate: () => s.gen(function* () {
356
+ x && clearInterval(x), c.deactivate && (yield* s.promise(() => c.deactivate()));
374
357
  })
375
358
  });
376
359
  };
377
360
  //#endregion
378
- export { e as RateLimited, n as consumeBucket, m as memoryStore, t as parseWindow, N as rateLimitPlugin, r as redisStore };
361
+ export { a as RATELIMIT_TABLE, e as RateLimited, n as consumeBucket, _ as memoryStore, t as parseWindow, r as postgresStore, P as rateLimitPlugin, i as rateLimitTable, o as redisStore };
@@ -0,0 +1,159 @@
1
+ import { Effect as e } from "effect";
2
+ import { eq as t, id as n, json as r, lt as i, queryFor as a, table as o, text as s, timestamp as c } from "@voltro/database";
3
+ //#region src/window.ts
4
+ var l = {
5
+ ms: 1,
6
+ s: 1e3,
7
+ m: 6e4,
8
+ h: 36e5,
9
+ d: 864e5
10
+ }, u = (e) => {
11
+ if (typeof e == "number") {
12
+ if (!Number.isFinite(e) || e <= 0) throw Error(`@voltro/plugin-ratelimit: window must be a positive number, got ${e}`);
13
+ return e;
14
+ }
15
+ let t = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(e.trim());
16
+ if (!t) throw Error(`@voltro/plugin-ratelimit: invalid window "${e}" — use e.g. "1m", "10s", "500ms", "2h", "1d"`);
17
+ return Number(t[1]) * l[t[2]];
18
+ }, d = (e, t, n) => {
19
+ switch (t.algorithm) {
20
+ case "fixed-window": return f(e, t, n);
21
+ case "sliding-window": return p(e, t, n);
22
+ case "token-bucket": return m(e, t, n);
23
+ }
24
+ }, f = (e, t, n) => {
25
+ let r = e?.kind === "fixed" ? e.windowStart : n, i = e?.kind === "fixed" ? e.count : 0;
26
+ n - r >= t.windowMs && (r = n, i = 0);
27
+ let a = r + t.windowMs;
28
+ return i >= t.limit ? {
29
+ state: {
30
+ kind: "fixed",
31
+ windowStart: r,
32
+ count: i
33
+ },
34
+ decision: {
35
+ allowed: !1,
36
+ remaining: 0,
37
+ resetAtMs: a,
38
+ retryAfterMs: a - n
39
+ }
40
+ } : (i += 1, {
41
+ state: {
42
+ kind: "fixed",
43
+ windowStart: r,
44
+ count: i
45
+ },
46
+ decision: {
47
+ allowed: !0,
48
+ remaining: t.limit - i,
49
+ resetAtMs: a,
50
+ retryAfterMs: 0
51
+ }
52
+ });
53
+ }, p = (e, t, n) => {
54
+ let r = t.windowMs, i = e?.kind === "sliding" ? e.currStart : n, a = e?.kind === "sliding" ? e.currCount : 0, o = e?.kind === "sliding" ? e.prevCount : 0, s = Math.floor((n - i) / r);
55
+ s >= 2 ? (o = 0, a = 0, i = n) : s === 1 && (o = a, a = 0, i += r);
56
+ let c = (r - (n - i)) / r, l = o * c + a, u = i + r;
57
+ return l >= t.limit ? {
58
+ state: {
59
+ kind: "sliding",
60
+ currStart: i,
61
+ currCount: a,
62
+ prevCount: o
63
+ },
64
+ decision: {
65
+ allowed: !1,
66
+ remaining: 0,
67
+ resetAtMs: u,
68
+ retryAfterMs: u - n
69
+ }
70
+ } : (a += 1, {
71
+ state: {
72
+ kind: "sliding",
73
+ currStart: i,
74
+ currCount: a,
75
+ prevCount: o
76
+ },
77
+ decision: {
78
+ allowed: !0,
79
+ remaining: Math.max(0, Math.floor(t.limit - l - 1)),
80
+ resetAtMs: u,
81
+ retryAfterMs: 0
82
+ }
83
+ });
84
+ }, m = (e, t, n) => {
85
+ let r = t.burst, i = t.limit / t.windowMs, a = e?.kind === "token" ? e.tokens : r, o = e?.kind === "token" ? e.lastRefill : n;
86
+ if (a = Math.min(r, a + (n - o) * i), a >= 1) {
87
+ --a;
88
+ let e = n + Math.ceil((r - a) / i);
89
+ return {
90
+ state: {
91
+ kind: "token",
92
+ tokens: a,
93
+ lastRefill: n
94
+ },
95
+ decision: {
96
+ allowed: !0,
97
+ remaining: Math.floor(a),
98
+ resetAtMs: e,
99
+ retryAfterMs: 0
100
+ }
101
+ };
102
+ }
103
+ let s = Math.ceil((1 - a) / i);
104
+ return {
105
+ state: {
106
+ kind: "token",
107
+ tokens: a,
108
+ lastRefill: n
109
+ },
110
+ decision: {
111
+ allowed: !1,
112
+ remaining: 0,
113
+ resetAtMs: n + s,
114
+ retryAfterMs: s
115
+ }
116
+ };
117
+ }, h = "_voltro_ratelimit", g = o(h, {
118
+ id: n({ scheme: "numeric" }),
119
+ key: s().maxLength(512),
120
+ state: r().nullable(),
121
+ touchedAt: c()
122
+ }).unique("rateLimitKey", ["key"]).nonReactive(), _ = (n = {}) => {
123
+ let r = n.staleAfterMs ?? 36e5, o = null, s = (e, t, n) => ({
124
+ failOpenReason: n,
125
+ allowed: !0,
126
+ remaining: e.limit,
127
+ resetAtMs: t + e.windowMs,
128
+ retryAfterMs: 0
129
+ }), c = async (e, n, r, i) => {
130
+ let o = new Date(i);
131
+ return await e.insertIgnore(h, {
132
+ key: n,
133
+ state: null,
134
+ touchedAt: o
135
+ }, { conflictColumns: ["key"] }), e.transactional(async (e) => {
136
+ let { state: s, decision: c } = d((await e.query(a(g).where(t("key", n)).forUpdate().descriptor))[0]?.state ?? void 0 ?? void 0, r, i);
137
+ return await e.updateMany(h, {
138
+ state: s,
139
+ touchedAt: o
140
+ }, { where: t("key", n) }), c;
141
+ });
142
+ };
143
+ return {
144
+ consume: (t, n, r) => {
145
+ let i = o;
146
+ return i ? e.tryPromise(() => c(i, t, n, r)).pipe(e.catchAll(() => e.succeed(s(n, r, "store-error")))) : e.succeed(s(n, r, "unbound"));
147
+ },
148
+ sweep: (e) => {
149
+ let t = o;
150
+ t && t.deleteMany(h, { where: i("touchedAt", new Date(e - r)) }).catch(() => {});
151
+ },
152
+ size: () => -1,
153
+ bind: async (e) => {
154
+ o = e;
155
+ }
156
+ };
157
+ };
158
+ //#endregion
159
+ export { u as a, d as i, _ as n, g as r, h as t };