@onesub/server 0.11.4 → 0.12.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/README.md +119 -2
- package/dist/__tests__/cache.test.d.ts +2 -0
- package/dist/__tests__/cache.test.d.ts.map +1 -0
- package/dist/__tests__/cache.test.js +34 -0
- package/dist/__tests__/cache.test.js.map +1 -0
- package/dist/__tests__/openapi.test.d.ts +2 -0
- package/dist/__tests__/openapi.test.d.ts.map +1 -0
- package/dist/__tests__/openapi.test.js +32 -0
- package/dist/__tests__/openapi.test.js.map +1 -0
- package/dist/__tests__/redis-store.test.d.ts +2 -0
- package/dist/__tests__/redis-store.test.d.ts.map +1 -0
- package/dist/__tests__/redis-store.test.js +117 -0
- package/dist/__tests__/redis-store.test.js.map +1 -0
- package/dist/__tests__/webhook-events.test.d.ts +2 -0
- package/dist/__tests__/webhook-events.test.d.ts.map +1 -0
- package/dist/__tests__/webhook-events.test.js +36 -0
- package/dist/__tests__/webhook-events.test.js.map +1 -0
- package/dist/__tests__/webhook-queue.test.d.ts +2 -0
- package/dist/__tests__/webhook-queue.test.d.ts.map +1 -0
- package/dist/__tests__/webhook-queue.test.js +26 -0
- package/dist/__tests__/webhook-queue.test.js.map +1 -0
- package/dist/cache.d.ts +32 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +43 -0
- package/dist/cache.js.map +1 -0
- package/dist/index.cjs +3114 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3072 -118
- package/dist/index.js.map +1 -1
- package/dist/openapi.d.ts +41 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +196 -0
- package/dist/openapi.js.map +1 -0
- package/dist/providers/apple.d.ts +1 -1
- package/dist/providers/apple.d.ts.map +1 -1
- package/dist/providers/apple.js +45 -34
- package/dist/providers/apple.js.map +1 -1
- package/dist/providers/google.d.ts.map +1 -1
- package/dist/providers/google.js +49 -20
- package/dist/providers/google.js.map +1 -1
- package/dist/routes/admin.d.ts +2 -1
- package/dist/routes/admin.d.ts.map +1 -1
- package/dist/routes/admin.js +37 -1
- package/dist/routes/admin.js.map +1 -1
- package/dist/routes/webhook.d.ts +2 -1
- package/dist/routes/webhook.d.ts.map +1 -1
- package/dist/routes/webhook.js +21 -1
- package/dist/routes/webhook.js.map +1 -1
- package/dist/stores/redis.d.ts +60 -0
- package/dist/stores/redis.d.ts.map +1 -0
- package/dist/stores/redis.js +255 -0
- package/dist/stores/redis.js.map +1 -0
- package/dist/stores/schema.d.ts.map +1 -1
- package/dist/stores/schema.js +10 -0
- package/dist/stores/schema.js.map +1 -1
- package/dist/tracing.d.ts +32 -0
- package/dist/tracing.d.ts.map +1 -0
- package/dist/tracing.js +74 -0
- package/dist/tracing.js.map +1 -0
- package/dist/webhook-events.d.ts +58 -0
- package/dist/webhook-events.d.ts.map +1 -0
- package/dist/webhook-events.js +57 -0
- package/dist/webhook-events.js.map +1 -0
- package/dist/webhook-queue.d.ts +92 -0
- package/dist/webhook-queue.d.ts.map +1 -0
- package/dist/webhook-queue.js +108 -0
- package/dist/webhook-queue.js.map +1 -0
- package/package.json +33 -4
- package/sql/schema.sql +10 -0
package/README.md
CHANGED
|
@@ -9,11 +9,17 @@ npm install @onesub/server
|
|
|
9
9
|
## Requirements
|
|
10
10
|
|
|
11
11
|
- Node.js **>= 20**
|
|
12
|
-
- **Express** as a peer dependency — `^4.17.0 || ^5.0.0
|
|
12
|
+
- **Express** as a peer dependency — `^4.17.0 || ^5.0.0`:
|
|
13
13
|
```bash
|
|
14
14
|
npm install @onesub/server express
|
|
15
15
|
```
|
|
16
|
-
-
|
|
16
|
+
- Optional peer dependencies (install only what you use):
|
|
17
|
+
```bash
|
|
18
|
+
npm install ioredis # Redis stores, cache, webhook idempotency
|
|
19
|
+
npm install bullmq # Durable webhook queue with dead-letter list
|
|
20
|
+
npm install pg # Postgres stores
|
|
21
|
+
npm install @opentelemetry/api # Distributed tracing
|
|
22
|
+
```
|
|
17
23
|
|
|
18
24
|
## Quick start
|
|
19
25
|
|
|
@@ -59,6 +65,9 @@ app.listen(4100);
|
|
|
59
65
|
| `DELETE /onesub/purchase/admin/:userId/:productId` | Wipe a non-consumable (requires `adminSecret`) |
|
|
60
66
|
| `POST /onesub/purchase/admin/grant` | Manually grant a purchase (requires `adminSecret`) |
|
|
61
67
|
| `POST /onesub/purchase/admin/transfer` | Reassign a `transactionId` to a new `userId` (requires `adminSecret`) |
|
|
68
|
+
| `GET /onesub/admin/webhook-deadletters` | List failed webhook jobs in DLQ (requires `adminSecret` + BullMQ) |
|
|
69
|
+
| `POST /onesub/admin/webhook-replay/:id` | Replay a dead-letter job (requires `adminSecret` + BullMQ) |
|
|
70
|
+
| `GET /openapi.json` | OpenAPI 3.1 spec (opt-in via `openapiHandler()`) |
|
|
62
71
|
|
|
63
72
|
## Lifecycle states (`0.4.0+`)
|
|
64
73
|
|
|
@@ -134,6 +143,114 @@ google: {
|
|
|
134
143
|
}
|
|
135
144
|
```
|
|
136
145
|
|
|
146
|
+
## Multi-instance deployments (`0.12.0+`)
|
|
147
|
+
|
|
148
|
+
By default every option uses in-process memory — zero config, zero infra. For multi-node clusters, swap in the Redis-backed equivalents:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
import Redis from 'ioredis';
|
|
152
|
+
import {
|
|
153
|
+
createOneSubMiddleware,
|
|
154
|
+
RedisSubscriptionStore,
|
|
155
|
+
RedisPurchaseStore,
|
|
156
|
+
RedisCacheAdapter,
|
|
157
|
+
RedisWebhookEventStore,
|
|
158
|
+
} from '@onesub/server';
|
|
159
|
+
|
|
160
|
+
const redis = new Redis(process.env.REDIS_URL!);
|
|
161
|
+
|
|
162
|
+
app.use(createOneSubMiddleware({
|
|
163
|
+
// ...apple / google / database config...
|
|
164
|
+
store: new RedisSubscriptionStore(redis),
|
|
165
|
+
purchaseStore: new RedisPurchaseStore(redis),
|
|
166
|
+
cache: new RedisCacheAdapter(redis), // share Apple JWT + Google OAuth tokens across nodes
|
|
167
|
+
webhookEventStore: new RedisWebhookEventStore(redis), // atomic SET NX dedup — one node handles each event
|
|
168
|
+
}));
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
| Class | Replaces | What it does |
|
|
172
|
+
|-------|----------|--------------|
|
|
173
|
+
| `RedisSubscriptionStore` | `InMemorySubscriptionStore` | Sorted-set index, newest-first pagination |
|
|
174
|
+
| `RedisPurchaseStore` | `InMemoryPurchaseStore` | Cross-node purchase ownership |
|
|
175
|
+
| `RedisCacheAdapter` | `InMemoryCacheAdapter` | Shared Apple JWT / Google OAuth token cache |
|
|
176
|
+
| `RedisWebhookEventStore` | `InMemoryWebhookEventStore` | Atomic `SET NX` dedup (no get→set race) |
|
|
177
|
+
|
|
178
|
+
### Webhook queue with durable retries
|
|
179
|
+
|
|
180
|
+
Replace the default in-process queue with BullMQ for decoupled retries and a dead-letter list:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import { BullMQWebhookQueue } from '@onesub/server';
|
|
184
|
+
import Redis from 'ioredis';
|
|
185
|
+
|
|
186
|
+
const connection = new Redis(process.env.REDIS_URL!, { maxRetriesPerRequest: null });
|
|
187
|
+
const webhookQueue = new BullMQWebhookQueue({ connection });
|
|
188
|
+
|
|
189
|
+
app.use(createOneSubMiddleware({
|
|
190
|
+
// ...
|
|
191
|
+
webhookQueue,
|
|
192
|
+
adminSecret: process.env.ADMIN_SECRET, // required for DLQ endpoints
|
|
193
|
+
}));
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The webhook route 200s immediately once the job is enqueued. The BullMQ worker retries up to 5× (exponential backoff, 1 s base) before moving the job to the dead-letter list. Inspect and replay via the admin endpoints above.
|
|
197
|
+
|
|
198
|
+
## Cache adapter (`0.12.0+`)
|
|
199
|
+
|
|
200
|
+
The `cache` option controls how Apple JWT assertions and Google OAuth access tokens are cached between requests. Default is `InMemoryCacheAdapter` (process-local). Shared Redis cache prevents every cluster node from minting its own token:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
import { InMemoryCacheAdapter, RedisCacheAdapter } from '@onesub/server';
|
|
204
|
+
|
|
205
|
+
// single instance (default — no config needed)
|
|
206
|
+
cache: new InMemoryCacheAdapter()
|
|
207
|
+
|
|
208
|
+
// multi-instance
|
|
209
|
+
cache: new RedisCacheAdapter(redis)
|
|
210
|
+
cache: new RedisCacheAdapter(redis, 'myapp:cache:') // custom key prefix
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
You can also pass any object implementing `CacheAdapter`:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
export interface CacheAdapter {
|
|
217
|
+
get<T>(key: string): Promise<T | null>;
|
|
218
|
+
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
|
|
219
|
+
del(key: string): Promise<void>;
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## OpenAPI (`0.12.0+`)
|
|
224
|
+
|
|
225
|
+
The full OpenAPI 3.1 document is exported as `ONESUB_OPENAPI`. Self-host it on your existing Express app:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
import { openapiHandler } from '@onesub/server';
|
|
229
|
+
|
|
230
|
+
app.get('/openapi.json', openapiHandler());
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Or import the spec directly for client generation:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
import { ONESUB_OPENAPI } from '@onesub/server';
|
|
237
|
+
// use with openapi-typescript, swagger-ui-express, etc.
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## OpenTelemetry tracing (`0.12.0+`)
|
|
241
|
+
|
|
242
|
+
Install `@opentelemetry/api` alongside an OTel SDK and spans appear automatically for receipt validation and webhook processing. The helper is zero-overhead when the package is absent — no `require` error, no performance hit.
|
|
243
|
+
|
|
244
|
+
You can wrap your own code in the same tracer:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
import { withSpan } from '@onesub/server';
|
|
248
|
+
|
|
249
|
+
const result = await withSpan('my-operation', { 'user.id': userId }, async () => {
|
|
250
|
+
return await doWork();
|
|
251
|
+
});
|
|
252
|
+
```
|
|
253
|
+
|
|
137
254
|
## Schema
|
|
138
255
|
|
|
139
256
|
Canonical Postgres DDL shipped at [`sql/schema.sql`](./sql/schema.sql). Apply with `psql -f` or let `store.initSchema()` run it for you on startup.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cache.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/cache.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { InMemoryCacheAdapter } from '../cache.js';
|
|
3
|
+
describe('InMemoryCacheAdapter', () => {
|
|
4
|
+
it('returns null for missing keys', async () => {
|
|
5
|
+
const cache = new InMemoryCacheAdapter();
|
|
6
|
+
expect(await cache.get('absent')).toBeNull();
|
|
7
|
+
});
|
|
8
|
+
it('round-trips a value', async () => {
|
|
9
|
+
const cache = new InMemoryCacheAdapter();
|
|
10
|
+
await cache.set('k', { token: 'abc', n: 1 });
|
|
11
|
+
expect(await cache.get('k')).toEqual({ token: 'abc', n: 1 });
|
|
12
|
+
});
|
|
13
|
+
it('expires entries after the TTL elapses', async () => {
|
|
14
|
+
const cache = new InMemoryCacheAdapter();
|
|
15
|
+
await cache.set('k', 'v', 1);
|
|
16
|
+
expect(await cache.get('k')).toBe('v');
|
|
17
|
+
// Force expiry by moving the clock forward via a sleep substitute.
|
|
18
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
19
|
+
expect(await cache.get('k')).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
it('treats ttl=0 / undefined as no-expiry', async () => {
|
|
22
|
+
const cache = new InMemoryCacheAdapter();
|
|
23
|
+
await cache.set('forever', 'v');
|
|
24
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
25
|
+
expect(await cache.get('forever')).toBe('v');
|
|
26
|
+
});
|
|
27
|
+
it('del removes a key', async () => {
|
|
28
|
+
const cache = new InMemoryCacheAdapter();
|
|
29
|
+
await cache.set('k', 'v');
|
|
30
|
+
await cache.del('k');
|
|
31
|
+
expect(await cache.get('k')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
//# sourceMappingURL=cache.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cache.test.js","sourceRoot":"","sources":["../../src/__tests__/cache.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAEnD,QAAQ,CAAC,sBAAsB,EAAE,GAAG,EAAE;IACpC,EAAE,CAAC,+BAA+B,EAAE,KAAK,IAAI,EAAE;QAC7C,MAAM,KAAK,GAAG,IAAI,oBAAoB,EAAE,CAAC;QACzC,MAAM,CAAC,MAAM,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;IAC/C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,qBAAqB,EAAE,KAAK,IAAI,EAAE;QACnC,MAAM,KAAK,GAAG,IAAI,oBAAoB,EAAE,CAAC;QACzC,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;QAC7C,MAAM,CAAC,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;IAC/D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,uCAAuC,EAAE,KAAK,IAAI,EAAE;QACrD,MAAM,KAAK,GAAG,IAAI,oBAAoB,EAAE,CAAC;QACzC,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC;QAC7B,MAAM,CAAC,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACvC,mEAAmE;QACnE,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC;QAC1D,MAAM,CAAC,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;IAC1C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,uCAAuC,EAAE,KAAK,IAAI,EAAE;QACrD,MAAM,KAAK,GAAG,IAAI,oBAAoB,EAAE,CAAC;QACzC,MAAM,KAAK,CAAC,GAAG,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;QAChC,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAC;QACxD,MAAM,CAAC,MAAM,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,mBAAmB,EAAE,KAAK,IAAI,EAAE;QACjC,MAAM,KAAK,GAAG,IAAI,oBAAoB,EAAE,CAAC;QACzC,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QAC1B,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACrB,MAAM,CAAC,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;IAC1C,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openapi.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/openapi.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ONESUB_OPENAPI } from '../openapi.js';
|
|
3
|
+
import { ROUTES } from '@onesub/shared';
|
|
4
|
+
describe('OpenAPI document', () => {
|
|
5
|
+
it('declares every public route', () => {
|
|
6
|
+
const declared = Object.keys(ONESUB_OPENAPI.paths);
|
|
7
|
+
// Spot-check the main public routes — the spec is hand-written, so this
|
|
8
|
+
// is the parity guard. Routes added to ROUTES that aren't in the spec
|
|
9
|
+
// should fail this test.
|
|
10
|
+
for (const path of [
|
|
11
|
+
ROUTES.VALIDATE,
|
|
12
|
+
ROUTES.STATUS,
|
|
13
|
+
ROUTES.WEBHOOK_APPLE,
|
|
14
|
+
ROUTES.WEBHOOK_GOOGLE,
|
|
15
|
+
ROUTES.ENTITLEMENTS,
|
|
16
|
+
ROUTES.ADMIN_SUBSCRIPTIONS,
|
|
17
|
+
]) {
|
|
18
|
+
expect(declared).toContain(path);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
it('uses 3.1.0', () => {
|
|
22
|
+
expect(ONESUB_OPENAPI.openapi).toBe('3.1.0');
|
|
23
|
+
});
|
|
24
|
+
it('declares the AdminSecret security scheme', () => {
|
|
25
|
+
expect(ONESUB_OPENAPI.components.securitySchemes.AdminSecret).toMatchObject({
|
|
26
|
+
type: 'apiKey',
|
|
27
|
+
in: 'header',
|
|
28
|
+
name: 'X-Admin-Secret',
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
//# sourceMappingURL=openapi.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openapi.test.js","sourceRoot":"","sources":["../../src/__tests__/openapi.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAExC,QAAQ,CAAC,kBAAkB,EAAE,GAAG,EAAE;IAChC,EAAE,CAAC,6BAA6B,EAAE,GAAG,EAAE;QACrC,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC;QACnD,wEAAwE;QACxE,sEAAsE;QACtE,yBAAyB;QACzB,KAAK,MAAM,IAAI,IAAI;YACjB,MAAM,CAAC,QAAQ;YACf,MAAM,CAAC,MAAM;YACb,MAAM,CAAC,aAAa;YACpB,MAAM,CAAC,cAAc;YACrB,MAAM,CAAC,YAAY;YACnB,MAAM,CAAC,mBAAmB;SAC3B,EAAE,CAAC;YACF,MAAM,CAAC,QAAQ,CAAC,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QACnC,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,YAAY,EAAE,GAAG,EAAE;QACpB,MAAM,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0CAA0C,EAAE,GAAG,EAAE;QAClD,MAAM,CAAC,cAAc,CAAC,UAAU,CAAC,eAAe,CAAC,WAAW,CAAC,CAAC,aAAa,CAAC;YAC1E,IAAI,EAAE,QAAQ;YACd,EAAE,EAAE,QAAQ;YACZ,IAAI,EAAE,gBAAgB;SACvB,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redis-store.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/redis-store.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from 'vitest';
|
|
2
|
+
import IORedisMock from 'ioredis-mock';
|
|
3
|
+
import { RedisSubscriptionStore, RedisPurchaseStore, RedisCacheAdapter } from '../stores/redis.js';
|
|
4
|
+
import { SUBSCRIPTION_STATUS } from '@onesub/shared';
|
|
5
|
+
function makeRedis() {
|
|
6
|
+
// ioredis-mock's surface matches ioredis enough for our store's methods
|
|
7
|
+
// (set/get/zadd/zrevrange/sadd/smembers/multi). Cast to keep TS happy.
|
|
8
|
+
return new IORedisMock();
|
|
9
|
+
}
|
|
10
|
+
const baseSub = {
|
|
11
|
+
originalTransactionId: 'tx-1',
|
|
12
|
+
userId: 'alice',
|
|
13
|
+
productId: 'pro_monthly',
|
|
14
|
+
platform: 'apple',
|
|
15
|
+
status: SUBSCRIPTION_STATUS.ACTIVE,
|
|
16
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
17
|
+
purchasedAt: '2025-01-01T00:00:00.000Z',
|
|
18
|
+
willRenew: true,
|
|
19
|
+
};
|
|
20
|
+
describe('RedisSubscriptionStore', () => {
|
|
21
|
+
let redis;
|
|
22
|
+
let store;
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
redis = makeRedis();
|
|
25
|
+
store = new RedisSubscriptionStore(redis);
|
|
26
|
+
});
|
|
27
|
+
it('save + getByTransactionId round-trip', async () => {
|
|
28
|
+
await store.save(baseSub);
|
|
29
|
+
const fetched = await store.getByTransactionId('tx-1');
|
|
30
|
+
expect(fetched).toEqual(baseSub);
|
|
31
|
+
});
|
|
32
|
+
it('getByUserId returns the most-recent record', async () => {
|
|
33
|
+
await store.save({ ...baseSub, originalTransactionId: 'tx-1', expiresAt: '2026-01-01T00:00:00.000Z' });
|
|
34
|
+
// Force a 1ms gap so the second save lands with a higher zadd score.
|
|
35
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
36
|
+
await store.save({ ...baseSub, originalTransactionId: 'tx-2', expiresAt: '2027-01-01T00:00:00.000Z' });
|
|
37
|
+
const latest = await store.getByUserId('alice');
|
|
38
|
+
expect(latest?.originalTransactionId).toBe('tx-2');
|
|
39
|
+
});
|
|
40
|
+
it('getAllByUserId returns every tx newest-first', async () => {
|
|
41
|
+
await store.save({ ...baseSub, originalTransactionId: 'tx-1' });
|
|
42
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
43
|
+
await store.save({ ...baseSub, originalTransactionId: 'tx-2' });
|
|
44
|
+
const all = await store.getAllByUserId('alice');
|
|
45
|
+
expect(all.map((s) => s.originalTransactionId)).toEqual(['tx-2', 'tx-1']);
|
|
46
|
+
});
|
|
47
|
+
it('listAll surfaces subs across users', async () => {
|
|
48
|
+
await store.save({ ...baseSub, originalTransactionId: 'tx-a', userId: 'alice' });
|
|
49
|
+
await store.save({ ...baseSub, originalTransactionId: 'tx-b', userId: 'bob' });
|
|
50
|
+
const all = await store.listAll();
|
|
51
|
+
expect(all).toHaveLength(2);
|
|
52
|
+
});
|
|
53
|
+
it('listFiltered respects userId + status filters', async () => {
|
|
54
|
+
await store.save({ ...baseSub, originalTransactionId: 'tx-a', userId: 'alice' });
|
|
55
|
+
await store.save({ ...baseSub, originalTransactionId: 'tx-b', userId: 'alice', status: SUBSCRIPTION_STATUS.EXPIRED });
|
|
56
|
+
const result = await store.listFiltered({ userId: 'alice', status: SUBSCRIPTION_STATUS.ACTIVE });
|
|
57
|
+
expect(result.total).toBe(1);
|
|
58
|
+
expect(result.items[0].originalTransactionId).toBe('tx-a');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
const basePurchase = {
|
|
62
|
+
transactionId: 'p-1',
|
|
63
|
+
userId: 'alice',
|
|
64
|
+
productId: 'remove_ads',
|
|
65
|
+
platform: 'apple',
|
|
66
|
+
type: 'non_consumable',
|
|
67
|
+
quantity: 1,
|
|
68
|
+
purchasedAt: '2025-01-01T00:00:00.000Z',
|
|
69
|
+
};
|
|
70
|
+
describe('RedisPurchaseStore', () => {
|
|
71
|
+
let redis;
|
|
72
|
+
let store;
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
redis = makeRedis();
|
|
75
|
+
store = new RedisPurchaseStore(redis);
|
|
76
|
+
});
|
|
77
|
+
it('savePurchase + getPurchaseByTransactionId round-trip', async () => {
|
|
78
|
+
await store.savePurchase(basePurchase);
|
|
79
|
+
const fetched = await store.getPurchaseByTransactionId('p-1');
|
|
80
|
+
expect(fetched).toEqual(basePurchase);
|
|
81
|
+
});
|
|
82
|
+
it('refuses cross-user reuse of the same transactionId', async () => {
|
|
83
|
+
await store.savePurchase(basePurchase);
|
|
84
|
+
await expect(store.savePurchase({ ...basePurchase, userId: 'mallory' })).rejects.toThrow(/TRANSACTION_BELONGS_TO_OTHER_USER/);
|
|
85
|
+
});
|
|
86
|
+
it('hasPurchased detects a non-consumable buy', async () => {
|
|
87
|
+
await store.savePurchase(basePurchase);
|
|
88
|
+
expect(await store.hasPurchased('alice', 'remove_ads')).toBe(true);
|
|
89
|
+
expect(await store.hasPurchased('alice', 'other_product')).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
it('reassignPurchase moves ownership without losing the row', async () => {
|
|
92
|
+
await store.savePurchase(basePurchase);
|
|
93
|
+
expect(await store.reassignPurchase('p-1', 'bob')).toBe(true);
|
|
94
|
+
const fetched = await store.getPurchaseByTransactionId('p-1');
|
|
95
|
+
expect(fetched?.userId).toBe('bob');
|
|
96
|
+
expect(await store.hasPurchased('alice', 'remove_ads')).toBe(false);
|
|
97
|
+
expect(await store.hasPurchased('bob', 'remove_ads')).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
it('deletePurchaseByTransactionId removes everywhere', async () => {
|
|
100
|
+
await store.savePurchase(basePurchase);
|
|
101
|
+
expect(await store.deletePurchaseByTransactionId('p-1')).toBe(true);
|
|
102
|
+
expect(await store.getPurchaseByTransactionId('p-1')).toBeNull();
|
|
103
|
+
expect(await store.hasPurchased('alice', 'remove_ads')).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe('RedisCacheAdapter', () => {
|
|
107
|
+
it('round-trips with TTL', async () => {
|
|
108
|
+
const cache = new RedisCacheAdapter(makeRedis());
|
|
109
|
+
await cache.set('k', { token: 'abc' }, 60);
|
|
110
|
+
expect(await cache.get('k')).toEqual({ token: 'abc' });
|
|
111
|
+
});
|
|
112
|
+
it('returns null for missing keys', async () => {
|
|
113
|
+
const cache = new RedisCacheAdapter(makeRedis());
|
|
114
|
+
expect(await cache.get('absent')).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
//# sourceMappingURL=redis-store.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redis-store.test.js","sourceRoot":"","sources":["../../src/__tests__/redis-store.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-events.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/webhook-events.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { CacheWebhookEventStore, InMemoryWebhookEventStore } from '../webhook-events.js';
|
|
3
|
+
import { InMemoryCacheAdapter } from '../cache.js';
|
|
4
|
+
describe('InMemoryWebhookEventStore', () => {
|
|
5
|
+
it('returns true the first time, false on retry', async () => {
|
|
6
|
+
const store = new InMemoryWebhookEventStore();
|
|
7
|
+
expect(await store.markIfNew('apple', 'uuid-1')).toBe(true);
|
|
8
|
+
expect(await store.markIfNew('apple', 'uuid-1')).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
it('keys by provider so apple/google ids never collide', async () => {
|
|
11
|
+
const store = new InMemoryWebhookEventStore();
|
|
12
|
+
expect(await store.markIfNew('apple', 'shared')).toBe(true);
|
|
13
|
+
expect(await store.markIfNew('google', 'shared')).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
it('expires entries after the TTL', async () => {
|
|
16
|
+
const store = new InMemoryWebhookEventStore(1); // 1-second TTL
|
|
17
|
+
expect(await store.markIfNew('apple', 'uuid-1')).toBe(true);
|
|
18
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
19
|
+
// Re-eligible after expiry — Apple/Google retries beyond the window are
|
|
20
|
+
// effectively new events, which is the intended behavior.
|
|
21
|
+
expect(await store.markIfNew('apple', 'uuid-1')).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe('CacheWebhookEventStore', () => {
|
|
25
|
+
it('uses any CacheAdapter for cluster-shared dedupe', async () => {
|
|
26
|
+
const cache = new InMemoryCacheAdapter();
|
|
27
|
+
const store = new CacheWebhookEventStore(cache);
|
|
28
|
+
expect(await store.markIfNew('google', 'msg-1')).toBe(true);
|
|
29
|
+
expect(await store.markIfNew('google', 'msg-1')).toBe(false);
|
|
30
|
+
// A second store backed by the same cache sees the prior write — this is
|
|
31
|
+
// the multi-instance idempotency guarantee we ship.
|
|
32
|
+
const store2 = new CacheWebhookEventStore(cache);
|
|
33
|
+
expect(await store2.markIfNew('google', 'msg-1')).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
//# sourceMappingURL=webhook-events.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-events.test.js","sourceRoot":"","sources":["../../src/__tests__/webhook-events.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,sBAAsB,EAAE,yBAAyB,EAAE,MAAM,sBAAsB,CAAC;AACzF,OAAO,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAEnD,QAAQ,CAAC,2BAA2B,EAAE,GAAG,EAAE;IACzC,EAAE,CAAC,6CAA6C,EAAE,KAAK,IAAI,EAAE;QAC3D,MAAM,KAAK,GAAG,IAAI,yBAAyB,EAAE,CAAC;QAC9C,MAAM,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC5D,MAAM,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAC/D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,oDAAoD,EAAE,KAAK,IAAI,EAAE;QAClE,MAAM,KAAK,GAAG,IAAI,yBAAyB,EAAE,CAAC;QAC9C,MAAM,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC5D,MAAM,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC/D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,+BAA+B,EAAE,KAAK,IAAI,EAAE;QAC7C,MAAM,KAAK,GAAG,IAAI,yBAAyB,CAAC,CAAC,CAAC,CAAC,CAAC,eAAe;QAC/D,MAAM,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC5D,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC;QAC1D,wEAAwE;QACxE,0DAA0D;QAC1D,MAAM,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC9D,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,wBAAwB,EAAE,GAAG,EAAE;IACtC,EAAE,CAAC,iDAAiD,EAAE,KAAK,IAAI,EAAE;QAC/D,MAAM,KAAK,GAAG,IAAI,oBAAoB,EAAE,CAAC;QACzC,MAAM,KAAK,GAAG,IAAI,sBAAsB,CAAC,KAAK,CAAC,CAAC;QAChD,MAAM,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC5D,MAAM,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAE7D,yEAAyE;QACzE,oDAAoD;QACpD,MAAM,MAAM,GAAG,IAAI,sBAAsB,CAAC,KAAK,CAAC,CAAC;QACjD,MAAM,CAAC,MAAM,MAAM,CAAC,SAAS,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAChE,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-queue.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/webhook-queue.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { InProcessWebhookQueue } from '../webhook-queue.js';
|
|
3
|
+
describe('InProcessWebhookQueue', () => {
|
|
4
|
+
it('runs the handler synchronously inside enqueue', async () => {
|
|
5
|
+
const queue = new InProcessWebhookQueue();
|
|
6
|
+
const seen = [];
|
|
7
|
+
queue.setHandler(async (job) => {
|
|
8
|
+
seen.push(job);
|
|
9
|
+
});
|
|
10
|
+
await queue.enqueue({ provider: 'apple', eventId: 'uuid-1', payload: { foo: 'bar' } });
|
|
11
|
+
expect(seen).toHaveLength(1);
|
|
12
|
+
expect(seen[0].eventId).toBe('uuid-1');
|
|
13
|
+
});
|
|
14
|
+
it('throws if no handler has been registered', async () => {
|
|
15
|
+
const queue = new InProcessWebhookQueue();
|
|
16
|
+
await expect(queue.enqueue({ provider: 'apple', eventId: 'uuid-1', payload: {} })).rejects.toThrow(/no handler/);
|
|
17
|
+
});
|
|
18
|
+
it('propagates handler errors to enqueue', async () => {
|
|
19
|
+
const queue = new InProcessWebhookQueue();
|
|
20
|
+
queue.setHandler(async () => {
|
|
21
|
+
throw new Error('downstream failed');
|
|
22
|
+
});
|
|
23
|
+
await expect(queue.enqueue({ provider: 'apple', eventId: 'uuid-1', payload: {} })).rejects.toThrow(/downstream failed/);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
//# sourceMappingURL=webhook-queue.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-queue.test.js","sourceRoot":"","sources":["../../src/__tests__/webhook-queue.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAG5D,QAAQ,CAAC,uBAAuB,EAAE,GAAG,EAAE;IACrC,EAAE,CAAC,+CAA+C,EAAE,KAAK,IAAI,EAAE;QAC7D,MAAM,KAAK,GAAG,IAAI,qBAAqB,EAAE,CAAC;QAC1C,MAAM,IAAI,GAAiB,EAAE,CAAC;QAC9B,KAAK,CAAC,UAAU,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE;YAC7B,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACjB,CAAC,CAAC,CAAC;QACH,MAAM,KAAK,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC;QACvF,MAAM,CAAC,IAAI,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;QAC7B,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACzC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0CAA0C,EAAE,KAAK,IAAI,EAAE;QACxD,MAAM,KAAK,GAAG,IAAI,qBAAqB,EAAE,CAAC;QAC1C,MAAM,MAAM,CACV,KAAK,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,CACrE,CAAC,OAAO,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;IAClC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sCAAsC,EAAE,KAAK,IAAI,EAAE;QACpD,MAAM,KAAK,GAAG,IAAI,qBAAqB,EAAE,CAAC;QAC1C,KAAK,CAAC,UAAU,CAAC,KAAK,IAAI,EAAE;YAC1B,MAAM,IAAI,KAAK,CAAC,mBAAmB,CAAC,CAAC;QACvC,CAAC,CAAC,CAAC;QACH,MAAM,MAAM,CACV,KAAK,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,CACrE,CAAC,OAAO,CAAC,OAAO,CAAC,mBAAmB,CAAC,CAAC;IACzC,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable cache adapter.
|
|
3
|
+
*
|
|
4
|
+
* Used internally by the Apple JWT minter, Google OAuth token minter, and any
|
|
5
|
+
* future per-process cache. The default `InMemoryCacheAdapter` matches the
|
|
6
|
+
* pre-adapter behavior — module-level state, lost on restart, not shared
|
|
7
|
+
* between cluster nodes.
|
|
8
|
+
*
|
|
9
|
+
* For multi-instance deployments, pass a `RedisCacheAdapter` in
|
|
10
|
+
* `OneSubMiddlewareConfig.cache` so all nodes share the same JWKS / token
|
|
11
|
+
* cache and avoid every node re-minting on its own schedule.
|
|
12
|
+
*/
|
|
13
|
+
export interface CacheAdapter {
|
|
14
|
+
get<T = unknown>(key: string): Promise<T | null>;
|
|
15
|
+
/** TTL in seconds. Pass 0 / undefined for no expiry. */
|
|
16
|
+
set<T = unknown>(key: string, value: T, ttlSeconds?: number): Promise<void>;
|
|
17
|
+
del(key: string): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Process-local in-memory cache. Default for single-instance deployments.
|
|
21
|
+
*/
|
|
22
|
+
export declare class InMemoryCacheAdapter implements CacheAdapter {
|
|
23
|
+
private readonly entries;
|
|
24
|
+
get<T>(key: string): Promise<T | null>;
|
|
25
|
+
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
|
|
26
|
+
del(key: string): Promise<void>;
|
|
27
|
+
/** Test helper — clear everything. Not part of the public CacheAdapter contract. */
|
|
28
|
+
clear(): void;
|
|
29
|
+
}
|
|
30
|
+
export declare function getDefaultCache(): CacheAdapter;
|
|
31
|
+
export declare function setDefaultCache(adapter: CacheAdapter): void;
|
|
32
|
+
//# sourceMappingURL=cache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,YAAY;IAC3B,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IACjD,wDAAwD;IACxD,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5E,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjC;AAQD;;GAEG;AACH,qBAAa,oBAAqB,YAAW,YAAY;IACvD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA4B;IAE9C,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAUtC,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKjE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIrC,oFAAoF;IACpF,KAAK,IAAI,IAAI;CAGd;AAQD,wBAAgB,eAAe,IAAI,YAAY,CAE9C;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAE3D"}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-local in-memory cache. Default for single-instance deployments.
|
|
3
|
+
*/
|
|
4
|
+
export class InMemoryCacheAdapter {
|
|
5
|
+
entries = new Map();
|
|
6
|
+
async get(key) {
|
|
7
|
+
const entry = this.entries.get(key);
|
|
8
|
+
if (!entry)
|
|
9
|
+
return null;
|
|
10
|
+
if (entry.expiresAt !== 0 && entry.expiresAt < Date.now()) {
|
|
11
|
+
this.entries.delete(key);
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
return entry.value;
|
|
15
|
+
}
|
|
16
|
+
async set(key, value, ttlSeconds) {
|
|
17
|
+
const expiresAt = ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : 0;
|
|
18
|
+
this.entries.set(key, { value, expiresAt });
|
|
19
|
+
}
|
|
20
|
+
async del(key) {
|
|
21
|
+
this.entries.delete(key);
|
|
22
|
+
}
|
|
23
|
+
/** Test helper — clear everything. Not part of the public CacheAdapter contract. */
|
|
24
|
+
clear() {
|
|
25
|
+
this.entries.clear();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Module-level singleton used by providers when no per-call adapter is
|
|
30
|
+
* supplied. Keeps the existing single-instance behavior with zero config.
|
|
31
|
+
*/
|
|
32
|
+
let defaultAdapter = new InMemoryCacheAdapter();
|
|
33
|
+
export function getDefaultCache() {
|
|
34
|
+
return defaultAdapter;
|
|
35
|
+
}
|
|
36
|
+
export function setDefaultCache(adapter) {
|
|
37
|
+
defaultAdapter = adapter;
|
|
38
|
+
}
|
|
39
|
+
/** Test helper — reset the default adapter to a fresh in-memory one. */
|
|
40
|
+
export function __resetDefaultCacheForTests() {
|
|
41
|
+
defaultAdapter = new InMemoryCacheAdapter();
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=cache.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cache.js","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAyBA;;GAEG;AACH,MAAM,OAAO,oBAAoB;IACd,OAAO,GAAG,IAAI,GAAG,EAAiB,CAAC;IAEpD,KAAK,CAAC,GAAG,CAAI,GAAW;QACtB,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACpC,IAAI,CAAC,KAAK;YAAE,OAAO,IAAI,CAAC;QACxB,IAAI,KAAK,CAAC,SAAS,KAAK,CAAC,IAAI,KAAK,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;YAC1D,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YACzB,OAAO,IAAI,CAAC;QACd,CAAC;QACD,OAAO,KAAK,CAAC,KAAU,CAAC;IAC1B,CAAC;IAED,KAAK,CAAC,GAAG,CAAI,GAAW,EAAE,KAAQ,EAAE,UAAmB;QACrD,MAAM,SAAS,GAAG,UAAU,IAAI,UAAU,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,UAAU,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QACpF,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC;IAC9C,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC3B,CAAC;IAED,oFAAoF;IACpF,KAAK;QACH,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;IACvB,CAAC;CACF;AAED;;;GAGG;AACH,IAAI,cAAc,GAAiB,IAAI,oBAAoB,EAAE,CAAC;AAE9D,MAAM,UAAU,eAAe;IAC7B,OAAO,cAAc,CAAC;AACxB,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,OAAqB;IACnD,cAAc,GAAG,OAAO,CAAC;AAC3B,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,2BAA2B;IACzC,cAAc,GAAG,IAAI,oBAAoB,EAAE,CAAC;AAC9C,CAAC"}
|