first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
# `database-store`
## Purpose
Provides a persistent `IdempotencyStoreAdapter<T>` backed by any relational database via the `IdempotencyDatabaseAdapter` interface. Stores idempotency records in a single flat table with JSON-serialised response columns. Auto-creates the table on first use when `autoCreate` is `true` (default), making zero-setup integration painless.
## Features
- Wraps any database that implements `IdempotencyDatabaseAdapter` (SQLite, PostgreSQL, MySQL, etc.)
- Auto-creates `idempotency_records` table on first use (configurable)
- Upsert semantics: `set` inserts or updates existing rows correctly
- JSON serialisation of arbitrary response payloads
- `cleanup(beforeTimestamp)` deletes expired rows and returns the removed count
- Generic `<T>` parameter for typed response payloads
- Injectable table name for multi-tenant or schema-isolated deployments
## Usage: Basic Example
```ts
import { DatabaseIdempotencyStore } from "@suaveplan/idempotency/store";
import type { IdempotencyDatabaseAdapter } from "@suaveplan/idempotency/store";
declare const myDbAdapter: IdempotencyDatabaseAdapter;
const store = new DatabaseIdempotencyStore({ adapter: myDbAdapter });
await store.set("order-123", {
key: "order-123", status: "completed", response: { id: 1 },
createdAt: Date.now(), updatedAt: Date.now(), expiresAt: Date.now() + 86400_000,
});
```
## Usage: Advanced Example
```ts
import { DatabaseIdempotencyStore } from "@suaveplan/idempotency/store";
const store = new DatabaseIdempotencyStore({
adapter: pgAdapter,
tableName: "payments.idempotency",
autoCreate: false, // table managed by migrations
});
// Periodic cleanup of expired records
setInterval(() => store.cleanup(Date.now()), 60_000);
```
## API Reference
### `new DatabaseIdempotencyStore<T>(config: DatabaseIdempotencyStoreConfig)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `adapter` | `IdempotencyDatabaseAdapter` | required | Database client adapter |
| `tableName` | `string` | `"idempotency_records"` | Table name |
| `autoCreate` | `boolean` | `true` | Auto-create table if absent |
### `get(key): Promise<IdempotencyRecord<T> | undefined>`
Queries the table for the given key. Returns `undefined` if not found.
### `set(key, record): Promise<void>`
Upserts the record: updates if key exists, inserts otherwise.
### `delete(key): Promise<boolean>`
Deletes the row; returns `true` if a row was removed.
### `cleanup(beforeTimestamp): Promise<number>`
Deletes all rows with `expires_at < beforeTimestamp`. Returns the count removed.
## Implementation Notes
- `ensureTable` runs once per instance (guarded by `initialized` flag); it is idempotent and can be disabled by setting `autoCreate: false`.
- JSON serialisation uses `JSON.parse` / `JSON.stringify`; circular references or non-serializable values will throw at `set` time.
- The `protected` visibility on `adapter`, `tableName`, `rowToRecord`, and `ensureTable` allows subclasses to extend without full re-implementation.
- No connection pooling or transaction management is performed — that responsibility belongs to the injected adapter.

View File

@@ -0,0 +1,85 @@
# `memory-store`
## Purpose
Provides an in-memory implementation of `IdempotencyStoreAdapter<T>` backed by a `Map`. It is the default store for unit tests, local development, and single-process deployments that do not need persistence across restarts. Records are lazily expired on `get` and can be batch-pruned via `cleanup`.
## Features
- O(1) `get` / `set` / `delete` via a `Map` keyed by idempotency key
- Lazy TTL expiration: expired records are detected and removed on `get`
- `cleanup(beforeTimestamp)` for batch removal of expired records
- Injectable `clock` function for deterministic testing with `FakeClock`
- `size` getter and `clear()` for test assertions and store resets
- Generic `<T>` type parameter propagated through stored records
## Usage: Basic Example
```ts
import { MemoryIdempotencyStore } from "@suaveplan/idempotency/store";
import { IdempotencyGuard } from "@suaveplan/idempotency/guard";
const store = new MemoryIdempotencyStore();
const guard = new IdempotencyGuard({ store, ttlMs: 3600_000 });
const result = await guard.execute("key-1", () => fetchUser(1));
console.log(store.size); // 1
```
## Usage: Advanced Example
```ts
import { MemoryIdempotencyStore } from "@suaveplan/idempotency/store";
// Inject a fake clock for deterministic TTL testing
let now = 1_000_000;
const fakeClock = () => now;
const store = new MemoryIdempotencyStore(fakeClock);
await store.set("k", {
key: "k", status: "completed", response: 42,
createdAt: now, updatedAt: now, expiresAt: now + 1000,
});
// Advance clock past TTL
now += 5000;
const result = await store.get("k"); // undefined — expired
```
## API Reference
### `new MemoryIdempotencyStore<T>(clock?: Clock)`
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `clock` | `() => number` | `Date.now` | Monotonic clock for TTL checks |
### `get(key): Promise<IdempotencyRecord<T> | undefined>`
Returns the record if it exists and has not expired. Deletes and returns `undefined` for expired records.
### `set(key, record): Promise<void>`
Upserts a record unconditionally.
### `delete(key): Promise<boolean>`
Removes the record; returns `true` if it existed.
### `cleanup(beforeTimestamp): Promise<number>`
Iterates all stored records and removes those whose `expiresAt <= beforeTimestamp`. Returns the count removed.
### `size: number`
Current number of records in the map, including expired entries not yet pruned.
### `clear(): void`
Immediately removes all records.
## Implementation Notes
- The `clock` is injected at construction time rather than captured from `Date.now` directly, enabling fully deterministic unit tests without patching globals.
- Lazy expiration keeps `get` at O(1) while `cleanup` is O(n) — call it periodically in long-running processes.
- The `protected` visibility on `records` and `clock` allows subclasses to extend without re-implementing the core logic.

View File

@@ -0,0 +1,76 @@
# `redis-store`
## Purpose
Provides a Redis-backed `IdempotencyStoreAdapter<T>` with automatic key expiration using Redis native TTL. By accepting a generic `RedisClientAdapter` interface rather than a concrete library, this module integrates with `ioredis`, `redis`, Upstash Redis, or any compatible client without coupling to a specific driver.
## Features
- JSON-serialised records stored as Redis string values
- Automatic TTL set on every `set()` call based on `record.expiresAt`
- Fallback TTL (`defaultTtlSeconds`) for records whose expiry is in the past
- Configurable key prefix for namespace isolation (multi-tenant)
- `cleanup()` scans expired records server-side for manual purging
- Generic `<T>` type parameter for typed payloads
## Usage: Basic Example
```ts
import { RedisIdempotencyStore } from "@suaveplan/idempotency/store";
import type { RedisClientAdapter } from "@suaveplan/idempotency/store";
declare const redisAdapter: RedisClientAdapter; // your ioredis wrapper
const store = new RedisIdempotencyStore({
client: redisAdapter,
prefix: "app:idempotency:",
});
```
## Usage: Advanced Example
```ts
import { RedisIdempotencyStore } from "@suaveplan/idempotency/store";
const store = new RedisIdempotencyStore({
client: redisAdapter,
prefix: `tenant:${tenantId}:idempotency:`,
defaultTtlSeconds: 3600, // 1 hour fallback
});
// Manual cleanup (most records expire via Redis TTL automatically)
const removed = await store.cleanup(Date.now());
console.log(`Manually removed ${removed} stale records`);
```
## API Reference
### `new RedisIdempotencyStore<T>(config: RedisIdempotencyStoreConfig)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `client` | `RedisClientAdapter` | required | Redis client adapter |
| `prefix` | `string` | `"idempotency:"` | Key namespace prefix |
| `defaultTtlSeconds` | `number` | `86400` | Fallback TTL when expiresAt is past |
### `get(key): Promise<IdempotencyRecord<T> | undefined>`
Fetches and JSON-parses the record. Returns `undefined` if not found (key expired or never set).
### `set(key, record): Promise<void>`
Serialises the record as JSON and stores it with a TTL computed from `record.expiresAt - Date.now()`.
### `delete(key): Promise<boolean>`
Calls `DEL` on the prefixed key; returns `true` if a key was removed.
### `cleanup(beforeTimestamp): Promise<number>`
Scans all keys under the prefix, parses each, and deletes those with `expiresAt < beforeTimestamp`. Returns the count removed.
## Implementation Notes
- Redis TTL handles the common expiration case automatically; `cleanup` is only needed for records that were not expired by Redis (e.g. records with very long TTLs where early manual deletion is desired).
- `scan` is used rather than `KEYS` to avoid blocking the Redis event loop in production.
- JSON serialisation assumes records are fully serialisable; `Date` objects should be stored as timestamps (numbers).