@sudobility/ratelimit_service 1.0.1

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.
Files changed (67) hide show
  1. package/CLAUDE.md +160 -0
  2. package/dist/helpers/EntitlementHelper.cjs +75 -0
  3. package/dist/helpers/EntitlementHelper.d.ts +52 -0
  4. package/dist/helpers/EntitlementHelper.d.ts.map +1 -0
  5. package/dist/helpers/EntitlementHelper.js +75 -0
  6. package/dist/helpers/EntitlementHelper.js.map +1 -0
  7. package/dist/helpers/RateLimitChecker.cjs +264 -0
  8. package/dist/helpers/RateLimitChecker.d.ts +90 -0
  9. package/dist/helpers/RateLimitChecker.d.ts.map +1 -0
  10. package/dist/helpers/RateLimitChecker.js +264 -0
  11. package/dist/helpers/RateLimitChecker.js.map +1 -0
  12. package/dist/helpers/RateLimitRouteHandler.cjs +191 -0
  13. package/dist/helpers/RateLimitRouteHandler.d.ts +70 -0
  14. package/dist/helpers/RateLimitRouteHandler.d.ts.map +1 -0
  15. package/dist/helpers/RateLimitRouteHandler.js +191 -0
  16. package/dist/helpers/RateLimitRouteHandler.js.map +1 -0
  17. package/dist/helpers/RevenueCatHelper.cjs +96 -0
  18. package/dist/helpers/RevenueCatHelper.d.ts +51 -0
  19. package/dist/helpers/RevenueCatHelper.d.ts.map +1 -0
  20. package/dist/helpers/RevenueCatHelper.js +96 -0
  21. package/dist/helpers/RevenueCatHelper.js.map +1 -0
  22. package/dist/helpers/index.cjs +10 -0
  23. package/dist/helpers/index.d.ts +4 -0
  24. package/dist/helpers/index.d.ts.map +1 -0
  25. package/dist/helpers/index.js +10 -0
  26. package/dist/helpers/index.js.map +1 -0
  27. package/dist/index.cjs +36 -0
  28. package/dist/index.d.ts +9 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +36 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/middleware/hono.cjs +94 -0
  33. package/dist/middleware/hono.d.ts +63 -0
  34. package/dist/middleware/hono.d.ts.map +1 -0
  35. package/dist/middleware/hono.js +94 -0
  36. package/dist/middleware/hono.js.map +1 -0
  37. package/dist/schema/rate-limits.cjs +136 -0
  38. package/dist/schema/rate-limits.d.ts +333 -0
  39. package/dist/schema/rate-limits.d.ts.map +1 -0
  40. package/dist/schema/rate-limits.js +136 -0
  41. package/dist/schema/rate-limits.js.map +1 -0
  42. package/dist/types/entitlements.cjs +9 -0
  43. package/dist/types/entitlements.d.ts +29 -0
  44. package/dist/types/entitlements.d.ts.map +1 -0
  45. package/dist/types/entitlements.js +9 -0
  46. package/dist/types/entitlements.js.map +1 -0
  47. package/dist/types/index.cjs +20 -0
  48. package/dist/types/index.d.ts +4 -0
  49. package/dist/types/index.d.ts.map +1 -0
  50. package/dist/types/index.js +20 -0
  51. package/dist/types/index.js.map +1 -0
  52. package/dist/types/rate-limits.cjs +3 -0
  53. package/dist/types/rate-limits.d.ts +34 -0
  54. package/dist/types/rate-limits.d.ts.map +1 -0
  55. package/dist/types/rate-limits.js +3 -0
  56. package/dist/types/rate-limits.js.map +1 -0
  57. package/dist/types/responses.cjs +13 -0
  58. package/dist/types/responses.d.ts +85 -0
  59. package/dist/types/responses.d.ts.map +1 -0
  60. package/dist/types/responses.js +13 -0
  61. package/dist/types/responses.js.map +1 -0
  62. package/dist/utils/time.cjs +180 -0
  63. package/dist/utils/time.d.ts +80 -0
  64. package/dist/utils/time.d.ts.map +1 -0
  65. package/dist/utils/time.js +180 -0
  66. package/dist/utils/time.js.map +1 -0
  67. package/package.json +79 -0
package/CLAUDE.md ADDED
@@ -0,0 +1,160 @@
1
+ # Subscription Service
2
+
3
+ Shared rate limiting library based on RevenueCat entitlements.
4
+
5
+ **npm**: `@sudobility/ratelimit_service` (public)
6
+
7
+ ## Tech Stack
8
+
9
+ - **Language**: TypeScript (strict mode)
10
+ - **Build**: Dual ESM/CJS output
11
+ - **Runtime**: Bun
12
+ - **Testing**: bun:test
13
+ - **Peer Dependencies**: drizzle-orm, hono
14
+
15
+ ## Commands
16
+
17
+ ```bash
18
+ bun run verify # All checks + build (use before commit)
19
+ bun test # Run tests
20
+ bun run lint # ESLint
21
+ bun run typecheck # TypeScript check
22
+ bun run build # Build ESM + CJS
23
+ ```
24
+
25
+ ## Project Structure
26
+
27
+ ```
28
+ src/
29
+ ├── index.ts # Main exports (re-exports from all modules)
30
+ ├── types/
31
+ │ ├── index.ts # Type re-exports
32
+ │ ├── rate-limits.ts # RateLimits, RateLimitsConfig
33
+ │ ├── entitlements.ts # RevenueCat types, NONE_ENTITLEMENT constant
34
+ │ └── responses.ts # RateLimitCheckResult, PeriodType enum
35
+ ├── schema/
36
+ │ └── rate-limits.ts # Drizzle schema template (consumers copy this)
37
+ ├── helpers/
38
+ │ ├── index.ts # Helper re-exports
39
+ │ ├── RevenueCatHelper.ts # Fetches entitlements from RevenueCat API
40
+ │ ├── EntitlementHelper.ts # Resolves rate limits from entitlements
41
+ │ └── RateLimitChecker.ts # Checks/increments counters in database
42
+ ├── middleware/
43
+ │ └── hono.ts # createRateLimitMiddleware factory
44
+ └── utils/
45
+ └── time.ts # Period calculation utilities
46
+ tests/
47
+ ├── EntitlementHelper.test.ts # Unit tests for EntitlementHelper
48
+ └── time.test.ts # Unit tests for time utilities
49
+ ```
50
+
51
+ ## Architecture
52
+
53
+ ### Data Flow
54
+ ```
55
+ Request → Middleware → RevenueCatHelper → EntitlementHelper → RateLimitChecker → Response
56
+ (fetch entitlements) (resolve limits) (check/increment DB)
57
+ ```
58
+
59
+ ### Key Classes
60
+
61
+ | Class | Purpose | Key Method |
62
+ |-------|---------|------------|
63
+ | `RevenueCatHelper` | Fetch user entitlements from RevenueCat API | `getSubscriptionInfo(userId)` |
64
+ | `EntitlementHelper` | Map entitlements to rate limits | `getRateLimits(entitlements)` |
65
+ | `RateLimitChecker` | Check limits and increment counters | `checkAndIncrement(userId, limits, subscriptionStartedAt)` |
66
+
67
+ ### Export Structure (package.json exports)
68
+ ```typescript
69
+ // Main entry: "@sudobility/ratelimit_service"
70
+ export { RevenueCatHelper, EntitlementHelper, RateLimitChecker, createRateLimitMiddleware }
71
+ export type { RateLimits, RateLimitsConfig, RateLimitCheckResult, ... }
72
+
73
+ // Middleware: "@sudobility/ratelimit_service/middleware/hono"
74
+ export { createRateLimitMiddleware }
75
+
76
+ // Schema: "@sudobility/ratelimit_service/schema"
77
+ export { rateLimitCounters }
78
+ ```
79
+
80
+ ## Business Logic
81
+
82
+ ### Rate Limits
83
+ - `undefined` = unlimited (no limit for that period)
84
+ - Limits checked in order: hourly → daily → monthly
85
+ - Multiple entitlements: upper-bound (most permissive) wins
86
+
87
+ ### Entitlement Resolution
88
+ ```typescript
89
+ const config: RateLimitsConfig = {
90
+ none: { hourly: 5, daily: 20, monthly: 100 }, // Required fallback
91
+ starter: { hourly: 10, daily: 50, monthly: 500 },
92
+ pro: { hourly: undefined, daily: undefined, monthly: undefined }, // unlimited
93
+ };
94
+
95
+ // Multiple entitlements → upper bound
96
+ getRateLimits(["starter", "pro"]) // → { hourly: undefined, daily: undefined, monthly: undefined }
97
+ ```
98
+
99
+ ### Subscription Month Calculation
100
+ Monthly periods are based on subscription start date, not calendar months:
101
+ - Subscription started March 5 → months are 3/5-4/4, 4/5-5/4, etc.
102
+ - Day overflow handled (Jan 31 → Feb 28 in non-leap year)
103
+ - No subscription → falls back to calendar month (1st of month)
104
+
105
+ ### Database Schema
106
+ Uses period-based counters with history preservation:
107
+ - One row per (user_id, period_type, period_start)
108
+ - period_type: 'hourly' | 'daily' | 'monthly'
109
+ - Old periods NOT deleted (enables usage history UI)
110
+
111
+ ## Coding Patterns
112
+
113
+ ### Type Imports
114
+ ```typescript
115
+ import type { RateLimits, RateLimitsConfig } from "../types/rate-limits";
116
+ import { NONE_ENTITLEMENT } from "../types/entitlements";
117
+ ```
118
+
119
+ ### Error Handling in Helpers
120
+ - RevenueCat 404 → return `["none"]` (user not found = no subscription)
121
+ - RevenueCat other errors → throw (let middleware handle)
122
+ - Unknown entitlement → fall back to "none" limits
123
+
124
+ ### Testing Pattern
125
+ ```typescript
126
+ import { describe, it, expect } from "bun:test";
127
+
128
+ describe("ClassName", () => {
129
+ describe("methodName", () => {
130
+ it("should do something", () => {
131
+ // Arrange
132
+ const helper = new ClassName(config);
133
+ // Act
134
+ const result = helper.method(input);
135
+ // Assert
136
+ expect(result).toEqual(expected);
137
+ });
138
+ });
139
+ });
140
+ ```
141
+
142
+ ### Time Utilities
143
+ All periods calculated in UTC:
144
+ ```typescript
145
+ getCurrentHourStart(now) // 14:35:22Z → 14:00:00Z
146
+ getCurrentDayStart(now) // 2025-01-15T14:35Z → 2025-01-15T00:00Z
147
+ getSubscriptionMonthStart(subStart, now) // Based on subscription day
148
+ ```
149
+
150
+ ## Consuming APIs
151
+
152
+ This library is used by:
153
+ - sudojo_api
154
+ - shapeshyft_api
155
+ - whisperly_api
156
+
157
+ Consumers must:
158
+ 1. Copy schema from `@sudobility/ratelimit_service/schema` to their db/schema.ts
159
+ 2. Run migrations to create the `rate_limit_counters` table
160
+ 3. Configure middleware with their RevenueCat API key and rate limits config
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EntitlementHelper = void 0;
4
+ const entitlements_1 = require("../types/entitlements");
5
+ /**
6
+ * Helper class for resolving rate limits from entitlements.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * const config: RateLimitsConfig = {
11
+ * none: { hourly: 2, daily: 5, monthly: 20 },
12
+ * starter: { hourly: 10, daily: 50, monthly: 500 },
13
+ * pro: { hourly: 100, daily: undefined, monthly: undefined },
14
+ * };
15
+ *
16
+ * const helper = new EntitlementHelper(config);
17
+ *
18
+ * // Single entitlement
19
+ * helper.getRateLimits("pro");
20
+ * // Returns: { hourly: 100, daily: undefined, monthly: undefined }
21
+ *
22
+ * // Multiple entitlements - returns upper bound
23
+ * helper.getRateLimits(["starter", "pro"]);
24
+ * // Returns: { hourly: 100, daily: undefined, monthly: undefined }
25
+ * ```
26
+ */
27
+ class EntitlementHelper {
28
+ constructor(config) {
29
+ this.config = config;
30
+ }
31
+ getRateLimits(entitlementOrArray) {
32
+ const entitlements = Array.isArray(entitlementOrArray)
33
+ ? entitlementOrArray
34
+ : [entitlementOrArray];
35
+ if (entitlements.length === 0) {
36
+ return this.config[entitlements_1.NONE_ENTITLEMENT];
37
+ }
38
+ if (entitlements.length === 1) {
39
+ const ent = entitlements[0];
40
+ return this.config[ent] ?? this.config[entitlements_1.NONE_ENTITLEMENT];
41
+ }
42
+ // Multiple entitlements - compute upper bound
43
+ return this.computeUpperBound(entitlements);
44
+ }
45
+ /**
46
+ * Compute the upper bound (most permissive) rate limits from multiple entitlements.
47
+ *
48
+ * Rules:
49
+ * - undefined (unlimited) beats any number
50
+ * - Higher numbers beat lower numbers
51
+ */
52
+ computeUpperBound(entitlements) {
53
+ const limits = entitlements.map(ent => this.config[ent] ?? this.config[entitlements_1.NONE_ENTITLEMENT]);
54
+ return {
55
+ hourly: this.maxLimit(limits.map(l => l.hourly)),
56
+ daily: this.maxLimit(limits.map(l => l.daily)),
57
+ monthly: this.maxLimit(limits.map(l => l.monthly)),
58
+ };
59
+ }
60
+ /**
61
+ * Get the maximum (most permissive) limit from an array.
62
+ * undefined (unlimited) always wins.
63
+ */
64
+ maxLimit(values) {
65
+ // If any value is undefined, result is unlimited
66
+ if (values.some(v => v === undefined)) {
67
+ return undefined;
68
+ }
69
+ // All values are defined, return the maximum
70
+ const definedValues = values;
71
+ return Math.max(...definedValues);
72
+ }
73
+ }
74
+ exports.EntitlementHelper = EntitlementHelper;
75
+ //# sourceMappingURL=EntitlementHelper.js.map
@@ -0,0 +1,52 @@
1
+ import type { RateLimits, RateLimitsConfig } from "../types/rate-limits";
2
+ /**
3
+ * Helper class for resolving rate limits from entitlements.
4
+ *
5
+ * @example
6
+ * ```typescript
7
+ * const config: RateLimitsConfig = {
8
+ * none: { hourly: 2, daily: 5, monthly: 20 },
9
+ * starter: { hourly: 10, daily: 50, monthly: 500 },
10
+ * pro: { hourly: 100, daily: undefined, monthly: undefined },
11
+ * };
12
+ *
13
+ * const helper = new EntitlementHelper(config);
14
+ *
15
+ * // Single entitlement
16
+ * helper.getRateLimits("pro");
17
+ * // Returns: { hourly: 100, daily: undefined, monthly: undefined }
18
+ *
19
+ * // Multiple entitlements - returns upper bound
20
+ * helper.getRateLimits(["starter", "pro"]);
21
+ * // Returns: { hourly: 100, daily: undefined, monthly: undefined }
22
+ * ```
23
+ */
24
+ export declare class EntitlementHelper {
25
+ private readonly config;
26
+ constructor(config: RateLimitsConfig);
27
+ /**
28
+ * Get rate limits for a single entitlement.
29
+ * Falls back to "none" limits if entitlement not found in config.
30
+ */
31
+ getRateLimits(entitlement: string): RateLimits;
32
+ /**
33
+ * Get rate limits for multiple entitlements.
34
+ * Returns the upper bound (most permissive) of all entitlements.
35
+ * undefined (unlimited) always wins over any number.
36
+ */
37
+ getRateLimits(entitlements: string[]): RateLimits;
38
+ /**
39
+ * Compute the upper bound (most permissive) rate limits from multiple entitlements.
40
+ *
41
+ * Rules:
42
+ * - undefined (unlimited) beats any number
43
+ * - Higher numbers beat lower numbers
44
+ */
45
+ private computeUpperBound;
46
+ /**
47
+ * Get the maximum (most permissive) limit from an array.
48
+ * undefined (unlimited) always wins.
49
+ */
50
+ private maxLimit;
51
+ }
52
+ //# sourceMappingURL=EntitlementHelper.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EntitlementHelper.d.ts","sourceRoot":"","sources":["../../src/helpers/EntitlementHelper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAGzE;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,qBAAa,iBAAiB;IAChB,OAAO,CAAC,QAAQ,CAAC,MAAM;gBAAN,MAAM,EAAE,gBAAgB;IAErD;;;OAGG;IACH,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,UAAU;IAE9C;;;;OAIG;IACH,aAAa,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,UAAU;IAoBjD;;;;;;OAMG;IACH,OAAO,CAAC,iBAAiB;IAYzB;;;OAGG;IACH,OAAO,CAAC,QAAQ;CAUjB"}
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EntitlementHelper = void 0;
4
+ const entitlements_1 = require("../types/entitlements");
5
+ /**
6
+ * Helper class for resolving rate limits from entitlements.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * const config: RateLimitsConfig = {
11
+ * none: { hourly: 2, daily: 5, monthly: 20 },
12
+ * starter: { hourly: 10, daily: 50, monthly: 500 },
13
+ * pro: { hourly: 100, daily: undefined, monthly: undefined },
14
+ * };
15
+ *
16
+ * const helper = new EntitlementHelper(config);
17
+ *
18
+ * // Single entitlement
19
+ * helper.getRateLimits("pro");
20
+ * // Returns: { hourly: 100, daily: undefined, monthly: undefined }
21
+ *
22
+ * // Multiple entitlements - returns upper bound
23
+ * helper.getRateLimits(["starter", "pro"]);
24
+ * // Returns: { hourly: 100, daily: undefined, monthly: undefined }
25
+ * ```
26
+ */
27
+ class EntitlementHelper {
28
+ constructor(config) {
29
+ this.config = config;
30
+ }
31
+ getRateLimits(entitlementOrArray) {
32
+ const entitlements = Array.isArray(entitlementOrArray)
33
+ ? entitlementOrArray
34
+ : [entitlementOrArray];
35
+ if (entitlements.length === 0) {
36
+ return this.config[entitlements_1.NONE_ENTITLEMENT];
37
+ }
38
+ if (entitlements.length === 1) {
39
+ const ent = entitlements[0];
40
+ return this.config[ent] ?? this.config[entitlements_1.NONE_ENTITLEMENT];
41
+ }
42
+ // Multiple entitlements - compute upper bound
43
+ return this.computeUpperBound(entitlements);
44
+ }
45
+ /**
46
+ * Compute the upper bound (most permissive) rate limits from multiple entitlements.
47
+ *
48
+ * Rules:
49
+ * - undefined (unlimited) beats any number
50
+ * - Higher numbers beat lower numbers
51
+ */
52
+ computeUpperBound(entitlements) {
53
+ const limits = entitlements.map(ent => this.config[ent] ?? this.config[entitlements_1.NONE_ENTITLEMENT]);
54
+ return {
55
+ hourly: this.maxLimit(limits.map(l => l.hourly)),
56
+ daily: this.maxLimit(limits.map(l => l.daily)),
57
+ monthly: this.maxLimit(limits.map(l => l.monthly)),
58
+ };
59
+ }
60
+ /**
61
+ * Get the maximum (most permissive) limit from an array.
62
+ * undefined (unlimited) always wins.
63
+ */
64
+ maxLimit(values) {
65
+ // If any value is undefined, result is unlimited
66
+ if (values.some(v => v === undefined)) {
67
+ return undefined;
68
+ }
69
+ // All values are defined, return the maximum
70
+ const definedValues = values;
71
+ return Math.max(...definedValues);
72
+ }
73
+ }
74
+ exports.EntitlementHelper = EntitlementHelper;
75
+ //# sourceMappingURL=EntitlementHelper.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EntitlementHelper.js","sourceRoot":"","sources":["../../src/helpers/EntitlementHelper.ts"],"names":[],"mappings":";;;AACA,wDAAyD;AAEzD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAa,iBAAiB;IAC5B,YAA6B,MAAwB;QAAxB,WAAM,GAAN,MAAM,CAAkB;IAAG,CAAC;IAezD,aAAa,CAAC,kBAAqC;QACjD,MAAM,YAAY,GAAG,KAAK,CAAC,OAAO,CAAC,kBAAkB,CAAC;YACpD,CAAC,CAAC,kBAAkB;YACpB,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC;QAEzB,IAAI,YAAY,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC9B,OAAO,IAAI,CAAC,MAAM,CAAC,+BAAgB,CAAC,CAAC;QACvC,CAAC;QAED,IAAI,YAAY,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC9B,MAAM,GAAG,GAAG,YAAY,CAAC,CAAC,CAAE,CAAC;YAC7B,OAAO,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,+BAAgB,CAAC,CAAC;QAC3D,CAAC;QAED,8CAA8C;QAC9C,OAAO,IAAI,CAAC,iBAAiB,CAAC,YAAY,CAAC,CAAC;IAC9C,CAAC;IAED;;;;;;OAMG;IACK,iBAAiB,CAAC,YAAsB;QAC9C,MAAM,MAAM,GAAG,YAAY,CAAC,GAAG,CAC7B,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,+BAAgB,CAAC,CACzD,CAAC;QAEF,OAAO;YACL,MAAM,EAAE,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;YAChD,KAAK,EAAE,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;YAC9C,OAAO,EAAE,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;SACnD,CAAC;IACJ,CAAC;IAED;;;OAGG;IACK,QAAQ,CAAC,MAA8B;QAC7C,iDAAiD;QACjD,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,SAAS,CAAC,EAAE,CAAC;YACtC,OAAO,SAAS,CAAC;QACnB,CAAC;QAED,6CAA6C;QAC7C,MAAM,aAAa,GAAG,MAAkB,CAAC;QACzC,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,aAAa,CAAC,CAAC;IACpC,CAAC;CACF;AAnED,8CAmEC"}
@@ -0,0 +1,264 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RateLimitChecker = void 0;
4
+ const drizzle_orm_1 = require("drizzle-orm");
5
+ const types_1 = require("../types");
6
+ const time_1 = require("../utils/time");
7
+ /**
8
+ * Checks and updates rate limits for a user.
9
+ * Uses period-based counters with history preservation.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * import { db, rateLimitCounters } from "./db";
14
+ *
15
+ * const checker = new RateLimitChecker({ db, table: rateLimitCounters });
16
+ *
17
+ * const result = await checker.checkAndIncrement(
18
+ * userId,
19
+ * { hourly: 10, daily: 100, monthly: undefined },
20
+ * subscriptionStartedAt // from RevenueCat
21
+ * );
22
+ *
23
+ * if (!result.allowed) {
24
+ * return c.json({ error: "Rate limit exceeded" }, 429);
25
+ * }
26
+ * ```
27
+ */
28
+ class RateLimitChecker {
29
+ constructor(config) {
30
+ this.db = config.db;
31
+ this.table = config.table;
32
+ }
33
+ /**
34
+ * Check if request is within rate limits and increment counters.
35
+ *
36
+ * @param userId - The user's ID
37
+ * @param limits - The rate limits to apply
38
+ * @param subscriptionStartedAt - When the subscription started (for monthly calculation)
39
+ * @returns Result indicating if request is allowed and remaining limits
40
+ */
41
+ async checkAndIncrement(userId, limits, subscriptionStartedAt = null) {
42
+ const now = new Date();
43
+ // Get current counts for each period type
44
+ const counts = await this.getCurrentCounts(userId, subscriptionStartedAt, now);
45
+ // Check limits before incrementing
46
+ const checkResult = this.checkLimits(counts, limits);
47
+ if (!checkResult.allowed) {
48
+ return checkResult;
49
+ }
50
+ // Increment counters for enabled limit types
51
+ await this.incrementCounters(userId, limits, subscriptionStartedAt, now);
52
+ // Calculate remaining after increment
53
+ const remaining = this.calculateRemaining({
54
+ hourly: counts.hourly + (limits.hourly !== undefined ? 1 : 0),
55
+ daily: counts.daily + (limits.daily !== undefined ? 1 : 0),
56
+ monthly: counts.monthly + (limits.monthly !== undefined ? 1 : 0),
57
+ }, limits);
58
+ return {
59
+ allowed: true,
60
+ statusCode: 200,
61
+ remaining,
62
+ limits,
63
+ };
64
+ }
65
+ /**
66
+ * Get current usage without incrementing (for status queries).
67
+ */
68
+ async checkOnly(userId, limits, subscriptionStartedAt = null) {
69
+ const now = new Date();
70
+ const counts = await this.getCurrentCounts(userId, subscriptionStartedAt, now);
71
+ const checkResult = this.checkLimits(counts, limits);
72
+ const remaining = this.calculateRemaining(counts, limits);
73
+ return {
74
+ ...checkResult,
75
+ remaining,
76
+ limits,
77
+ };
78
+ }
79
+ /**
80
+ * Get usage history for a user.
81
+ *
82
+ * @param userId - The user's ID
83
+ * @param periodType - The period type to get history for
84
+ * @param subscriptionStartedAt - When the subscription started (for calculating period_end)
85
+ * @param limit - Maximum number of entries to return (default: 100)
86
+ * @returns Usage history with period start/end and counts
87
+ */
88
+ async getHistory(userId, periodType, subscriptionStartedAt = null, limit = 100) {
89
+ const tableAny = this.table;
90
+ const rows = await this.db
91
+ .select()
92
+ .from(this.table)
93
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(tableAny.user_id, userId), (0, drizzle_orm_1.eq)(tableAny.period_type, periodType)))
94
+ .orderBy((0, drizzle_orm_1.desc)(tableAny.period_start))
95
+ .limit(limit);
96
+ const entries = rows.map(row => {
97
+ const counter = row;
98
+ return {
99
+ period_start: counter.period_start,
100
+ period_end: this.getPeriodEnd(periodType, counter.period_start, subscriptionStartedAt),
101
+ request_count: counter.request_count,
102
+ };
103
+ });
104
+ return {
105
+ user_id: userId,
106
+ period_type: periodType,
107
+ entries,
108
+ };
109
+ }
110
+ /**
111
+ * Get current counts for each period type.
112
+ */
113
+ async getCurrentCounts(userId, subscriptionStartedAt, now) {
114
+ const [hourlyCount, dailyCount, monthlyCount] = await Promise.all([
115
+ this.getCountForPeriod(userId, types_1.PeriodType.HOURLY, (0, time_1.getCurrentHourStart)(now)),
116
+ this.getCountForPeriod(userId, types_1.PeriodType.DAILY, (0, time_1.getCurrentDayStart)(now)),
117
+ this.getCountForPeriod(userId, types_1.PeriodType.MONTHLY, (0, time_1.getSubscriptionMonthStart)(subscriptionStartedAt, now)),
118
+ ]);
119
+ return {
120
+ hourly: hourlyCount,
121
+ daily: dailyCount,
122
+ monthly: monthlyCount,
123
+ };
124
+ }
125
+ /**
126
+ * Get the counter value for a specific period.
127
+ */
128
+ async getCountForPeriod(userId, periodType, periodStart) {
129
+ const tableAny = this.table;
130
+ const rows = await this.db
131
+ .select()
132
+ .from(this.table)
133
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(tableAny.user_id, userId), (0, drizzle_orm_1.eq)(tableAny.period_type, periodType), (0, drizzle_orm_1.eq)(tableAny.period_start, periodStart)))
134
+ .limit(1);
135
+ if (rows.length === 0) {
136
+ return 0;
137
+ }
138
+ const counter = rows[0];
139
+ return counter.request_count;
140
+ }
141
+ /**
142
+ * Increment counters for enabled limit types.
143
+ */
144
+ async incrementCounters(userId, limits, subscriptionStartedAt, now) {
145
+ const updates = [];
146
+ if (limits.hourly !== undefined) {
147
+ updates.push(this.incrementPeriodCounter(userId, types_1.PeriodType.HOURLY, (0, time_1.getCurrentHourStart)(now), now));
148
+ }
149
+ if (limits.daily !== undefined) {
150
+ updates.push(this.incrementPeriodCounter(userId, types_1.PeriodType.DAILY, (0, time_1.getCurrentDayStart)(now), now));
151
+ }
152
+ if (limits.monthly !== undefined) {
153
+ updates.push(this.incrementPeriodCounter(userId, types_1.PeriodType.MONTHLY, (0, time_1.getSubscriptionMonthStart)(subscriptionStartedAt, now), now));
154
+ }
155
+ await Promise.all(updates);
156
+ }
157
+ /**
158
+ * Increment a specific period counter (upsert).
159
+ */
160
+ async incrementPeriodCounter(userId, periodType, periodStart, now) {
161
+ const tableAny = this.table;
162
+ // Try to find existing counter
163
+ const existing = await this.db
164
+ .select()
165
+ .from(this.table)
166
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(tableAny.user_id, userId), (0, drizzle_orm_1.eq)(tableAny.period_type, periodType), (0, drizzle_orm_1.eq)(tableAny.period_start, periodStart)))
167
+ .limit(1);
168
+ if (existing.length > 0) {
169
+ // Update existing counter
170
+ const counter = existing[0];
171
+ await this.db
172
+ .update(this.table)
173
+ .set({
174
+ request_count: counter.request_count + 1,
175
+ updated_at: now,
176
+ })
177
+ .where((0, drizzle_orm_1.eq)(tableAny.id, counter.id));
178
+ }
179
+ else {
180
+ // Insert new counter
181
+ await this.db.insert(this.table).values({
182
+ user_id: userId,
183
+ period_type: periodType,
184
+ period_start: periodStart,
185
+ request_count: 1,
186
+ created_at: now,
187
+ updated_at: now,
188
+ });
189
+ }
190
+ }
191
+ /**
192
+ * Check limits and return result.
193
+ */
194
+ checkLimits(counts, limits) {
195
+ const remaining = this.calculateRemaining(counts, limits);
196
+ // Check hourly limit
197
+ if (limits.hourly !== undefined && counts.hourly >= limits.hourly) {
198
+ return {
199
+ allowed: false,
200
+ statusCode: 429,
201
+ remaining,
202
+ exceededLimit: "hourly",
203
+ limits,
204
+ };
205
+ }
206
+ // Check daily limit
207
+ if (limits.daily !== undefined && counts.daily >= limits.daily) {
208
+ return {
209
+ allowed: false,
210
+ statusCode: 429,
211
+ remaining,
212
+ exceededLimit: "daily",
213
+ limits,
214
+ };
215
+ }
216
+ // Check monthly limit
217
+ if (limits.monthly !== undefined && counts.monthly >= limits.monthly) {
218
+ return {
219
+ allowed: false,
220
+ statusCode: 429,
221
+ remaining,
222
+ exceededLimit: "monthly",
223
+ limits,
224
+ };
225
+ }
226
+ return {
227
+ allowed: true,
228
+ statusCode: 200,
229
+ remaining,
230
+ limits,
231
+ };
232
+ }
233
+ /**
234
+ * Calculate remaining requests for each period.
235
+ */
236
+ calculateRemaining(counts, limits) {
237
+ return {
238
+ hourly: limits.hourly !== undefined
239
+ ? Math.max(0, limits.hourly - counts.hourly)
240
+ : undefined,
241
+ daily: limits.daily !== undefined
242
+ ? Math.max(0, limits.daily - counts.daily)
243
+ : undefined,
244
+ monthly: limits.monthly !== undefined
245
+ ? Math.max(0, limits.monthly - counts.monthly)
246
+ : undefined,
247
+ };
248
+ }
249
+ /**
250
+ * Get the end of a period for history entries.
251
+ */
252
+ getPeriodEnd(periodType, periodStart, subscriptionStartedAt) {
253
+ switch (periodType) {
254
+ case types_1.PeriodType.HOURLY:
255
+ return (0, time_1.getNextHourStart)(periodStart);
256
+ case types_1.PeriodType.DAILY:
257
+ return (0, time_1.getNextDayStart)(periodStart);
258
+ case types_1.PeriodType.MONTHLY:
259
+ return (0, time_1.getNextSubscriptionMonthStart)(subscriptionStartedAt, periodStart);
260
+ }
261
+ }
262
+ }
263
+ exports.RateLimitChecker = RateLimitChecker;
264
+ //# sourceMappingURL=RateLimitChecker.js.map