wolverine-ai 1.6.1 → 1.7.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.
package/README.md CHANGED
@@ -185,17 +185,19 @@ Most production bugs don't crash the process — Fastify/Express catch them and
185
185
  ```
186
186
  Route returns 500 (process still alive)
187
187
  → Error hook reports to parent via IPC (auto-injected, zero user code changes)
188
- → ErrorMonitor tracks consecutive 500s per route
189
- 3 failures in 30s → triggers heal pipeline (same as crash healing)
188
+ → ErrorMonitor tracks errors per normalized route (/api/users/:id)
189
+ Single error triggers heal pipeline immediately (configurable threshold)
190
190
  → Fix applied → server restarted → route prober verifies fix
191
191
  ```
192
192
 
193
193
  | Setting | Default | Env Variable |
194
194
  |---------|---------|-------------|
195
- | Failure threshold | 3 | `WOLVERINE_ERROR_THRESHOLD` |
195
+ | Failure threshold | 1 | `WOLVERINE_ERROR_THRESHOLD` |
196
196
  | Time window | 30s | `WOLVERINE_ERROR_WINDOW_MS` |
197
197
  | Cooldown per route | 60s | `WOLVERINE_ERROR_COOLDOWN_MS` |
198
198
 
199
+ Routes are auto-normalized: `/api/users/123` and `/api/users/456` aggregate as `/api/users/:id`.
200
+
199
201
  The error hook auto-patches Fastify and Express via `--require` preload. No middleware, no code changes to your server.
200
202
 
201
203
  ---
@@ -315,7 +317,7 @@ Reasoning models (`o-series`, `gpt-5-nano`) automatically get 4x token limits to
315
317
  | **Admin Auth** | Dashboard requires key + IP allowlist. Localhost always allowed. Remote IPs via `WOLVERINE_ADMIN_IPS` env var or `POST /api/admin/add-ip` at runtime. Timing-safe comparison, lockout after 10 failures |
316
318
  | **Rate Limiter** | Sliding window, min gap, hourly budget, exponential backoff on error loops |
317
319
  | **MCP Security** | Per-server tool allowlists, arg sanitization, result injection scanning |
318
- | **SQL Skill** | `sqlGuard()` middleware blocks 15 injection pattern families on all endpoints |
320
+ | **SQL Skill** | `sqlGuard()` blocks 15 injection pattern families; `idempotencyGuard()` prevents double-fire in cluster mode |
319
321
 
320
322
  ---
321
323
 
@@ -354,17 +356,35 @@ The `📊 Analytics` dashboard panel shows memory/CPU charts, route health statu
354
356
 
355
357
  ---
356
358
 
357
- ## Auto-Clustering
359
+ ## Cluster Mode
358
360
 
359
- Wolverine detects your machine and forks the optimal number of workers:
361
+ The server handles its own clustering. Wolverine is the single process manager — it spawns your server, which forks workers internally.
360
362
 
361
363
  ```bash
362
- wolverine server/index.js # auto-detect: 20 cores → 10 workers
363
- wolverine server/index.js --single # force single worker (dev mode)
364
- wolverine server/index.js --workers 4 # force 4 workers
365
- wolverine --info # show system capabilities
364
+ # Enable cluster mode
365
+ WOLVERINE_CLUSTER=true wolverine server/index.js
366
+
367
+ # System info (cores, RAM, recommended workers)
368
+ wolverine --info
369
+ ```
370
+
371
+ **How it works:**
372
+ ```
373
+ Wolverine (single process manager)
374
+ └── spawns server/index.js
375
+ ├── WOLVERINE_CLUSTER=false → single server (default)
376
+ └── WOLVERINE_CLUSTER=true → master forks N workers
377
+ ├── Worker 1 (port 3000, reusePort)
378
+ ├── Worker 2 (port 3000, reusePort)
379
+ └── Worker N (port 3000, reusePort)
366
380
  ```
367
381
 
382
+ - **`WOLVERINE_RECOMMENDED_WORKERS`** auto-set based on CPU cores/RAM
383
+ - Workers share port 3000 via `reusePort` — OS handles load balancing
384
+ - Dead workers auto-respawn by the master process
385
+ - Wolverine kills the **entire process tree** on restart (no orphaned workers)
386
+ - **Idempotency protection** prevents double-fire across workers (see below)
387
+
368
388
  **System detection:**
369
389
  - CPU cores, model, speed
370
390
  - Total/free RAM, disk space
@@ -372,18 +392,6 @@ wolverine --info # show system capabilities
372
392
  - Container environment (Docker, Kubernetes)
373
393
  - Cloud provider (AWS, GCP, Azure, Railway, Fly, Render, Heroku)
374
394
 
375
- **Scaling rules:**
376
-
377
- | Cores | Workers |
378
- |-------|---------|
379
- | 1 | 1 (no clustering) |
380
- | 2 | 2 |
381
- | 3-4 | cores - 1 |
382
- | 5-8 | cores - 1, cap 6 |
383
- | 9+ | cores / 2, cap 16 |
384
-
385
- Workers auto-respawn on crash with exponential backoff (1s → 30s). Max 5 restarts per worker.
386
-
387
395
  ---
388
396
 
389
397
  ## Configuration
@@ -412,8 +420,8 @@ Startup:
412
420
  - Auto-registers on first run, retries every 60s until platform responds
413
421
  - Saves key to `.wolverine/platform-key` (survives restarts)
414
422
  - Sends one ~2KB JSON POST every 60 seconds (5s timeout, non-blocking)
415
- - Payload matches [PLATFORM.md](PLATFORM.md) spec: `instanceId`, `server`, `process`, `routes`, `repairs`, `usage` (tokens/cost/calls + `byCategory` + `byModel` + `byTool`), `brain`, `backups`
416
- - Platform analytics aggregates across all servers: total tokens/cost, breakdown by category (heal/chat/develop/security/classify/research/brain), by model, by tool
423
+ - Payload: `instanceId`, `server`, `process`, `routes`, `repairs`, `usage` (tokens/cost/calls + `byCategory` + `byModel` + `byTool`), `brain`, `backups`
424
+ - Platform aggregates across all servers: total tokens/cost by category, model, tool
417
425
  - Secrets redacted before sending
418
426
  - Offline-resilient: queues up to 1440 heartbeats locally, drains on reconnect
419
427
 
@@ -422,7 +430,7 @@ Startup:
422
430
  **Override:** `WOLVERINE_PLATFORM_URL=https://your-own-platform.com`
423
431
  **Opt out:** `WOLVERINE_TELEMETRY=false`
424
432
 
425
- See [PLATFORM.md](PLATFORM.md) for the backend spec and [TELEMETRY.md](TELEMETRY.md) for the protocol.
433
+ Telemetry payload includes: `instanceId`, `server`, `process`, `routes`, `repairs`, `usage` (by category/model/tool), `brain`, `backups`.
426
434
 
427
435
  ---
428
436
 
@@ -478,14 +486,53 @@ Full `server/` directory snapshots with lifecycle management:
478
486
  Auto-discovered from `src/skills/`. Each skill exports metadata for the registry:
479
487
 
480
488
  ### SQL Skill (`src/skills/sql.js`)
481
- - **sqlGuard()** — Express middleware blocking SQL injection (UNION, stacked queries, tautologies, timing attacks, etc.)
482
- - **SafeDB** — Parameterized-only database wrapper (blocks string concatenation in queries)
489
+ - **sqlGuard()** — Fastify/Express middleware blocking SQL injection (UNION, stacked queries, tautologies, timing attacks, 15 pattern families)
490
+ - **SafeDB** — Cluster-safe database with split read/write connections, FIFO write queue, WAL mode
491
+ - **idempotencyGuard()** — Prevents double-fire of write requests in cluster mode (see below)
492
+ - **db.idempotent(key, fn)** — Database-level dedup for critical writes (payments, orders)
483
493
  - Auto-injected into agent prompts when building database features
484
494
 
485
495
  Add new skills by creating a file in `src/skills/` with `SKILL_NAME`, `SKILL_DESCRIPTION`, `SKILL_KEYWORDS`, `SKILL_USAGE` exports.
486
496
 
487
497
  ---
488
498
 
499
+ ## Idempotency (Double-Fire Protection)
500
+
501
+ In cluster mode, a retry or duplicate request can land on a different worker and execute twice. Two layers prevent this:
502
+
503
+ ### Layer 1: HTTP Middleware
504
+
505
+ ```javascript
506
+ const { idempotencyGuard, idempotencyAfterHook } = require("wolverine-ai");
507
+
508
+ fastify.addHook("preHandler", idempotencyGuard({ db, logger }));
509
+ fastify.addHook("onSend", idempotencyAfterHook(db));
510
+ ```
511
+
512
+ - Client sends `X-Idempotency-Key: order-abc-123` header
513
+ - Without header: auto-generates key from `sha256(method + url + body)`
514
+ - First request: executes handler, caches response in shared SQLite table
515
+ - Duplicate: returns cached response with `X-Idempotency-Cached: true` header
516
+ - Safe methods (GET/HEAD/OPTIONS) always pass through
517
+ - Keys expire after 24h (configurable)
518
+
519
+ ### Layer 2: Database-Level
520
+
521
+ ```javascript
522
+ const result = await db.idempotent("charge-abc-123", (tx) => {
523
+ tx.run("INSERT INTO charges (id, amount) VALUES (?, ?)", ["abc-123", 99.99]);
524
+ tx.run("UPDATE balance SET amount = amount - ? WHERE user_id = ?", [99.99, 1]);
525
+ return { charged: true };
526
+ });
527
+ // result.executed = true (first time) or false (duplicate)
528
+ ```
529
+
530
+ - Wraps `fn` in a transaction with idempotency key check
531
+ - All workers share the `_idempotency` table via WAL mode — globally consistent
532
+ - Auto-created on `db.connect()`, pruned via `db.pruneIdempotency()`
533
+
534
+ ---
535
+
489
536
  ## MCP Integration
490
537
 
491
538
  Connect external tools via [Model Context Protocol](https://modelcontextprotocol.io):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wolverine-ai",
3
- "version": "1.6.1",
3
+ "version": "1.7.1",
4
4
  "description": "Self-healing Node.js server framework powered by AI. Catches crashes, diagnoses errors, generates fixes, verifies, and restarts — automatically.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -96,7 +96,7 @@ const SEED_DOCS = [
96
96
  metadata: { topic: "skill-sql-patterns" },
97
97
  },
98
98
  {
99
- text: "Database best practices: SafeDB uses split connections — separate read connection (concurrent, never waits) and write connection (single writer, FIFO queue). Write queue drains synchronously in one microtask, zero delays. WAL mode means readers never block writers. Each write is microseconds. db.transaction(fn) queues as single atomic unit. No busy_timeout, no blocking, no IPC. Reads: db.get(), db.all() are instant. Writes: db.run(), db.exec() go through queue.",
99
+ text: "Database best practices: SafeDB uses split connections — separate read connection (concurrent, never waits) and write connection (single writer, FIFO queue). Write queue drains synchronously in one microtask, zero delays. WAL mode means readers never block writers. Each write is microseconds. db.transaction(fn) queues as single atomic unit. No busy_timeout, no blocking, no IPC. Reads: db.get(), db.all() are instant. Writes: db.run(), db.exec() go through queue. Idempotent writes: db.idempotent(key, fn, ttlSeconds) executes fn only once per key — prevents double-charge/double-insert when retries or cluster workers duplicate a request. Idempotency keys stored in _idempotency table (auto-created on connect), shared across all workers via WAL mode.",
100
100
  metadata: { topic: "skill-sql-best-practices" },
101
101
  },
102
102
  {
@@ -120,7 +120,7 @@ const SEED_DOCS = [
120
120
  metadata: { topic: "process-manager" },
121
121
  },
122
122
  {
123
- text: "Auto-clustering: wolverine detects machine capabilities (cores, RAM, disk, platform, Docker/K8s, cloud provider) and forks optimal workers. 2 cores = 2 workers, 3-4 = cores-1, 5-8 = cores-1 cap 6, 9+ = cores/2 cap 16. Workers auto-respawn on crash with exponential backoff. CLI: --single (no cluster), --workers N (fixed), --info (show system). Settings in server/config/settings.json cluster.mode.",
123
+ text: "Cluster mode: server handles its own clustering (not wolverine-level). WOLVERINE_CLUSTER=true enables it. Server forks N workers (WOLVERINE_RECOMMENDED_WORKERS set by system detection). Workers share port 3000 via reusePort. Wolverine kills entire process tree on restart (_killProcessTree: taskkill /T on Windows, kill -pgid + pgrep -P on Linux). Idempotency protection prevents double-fire: idempotencyGuard() middleware deduplicates write requests across workers using shared SQLite _idempotency table. Client sends X-Idempotency-Key header, or auto-generated from method+path+body hash. All workers see the same table via WAL mode. SafeDB.idempotent(key, fn) for database-level dedup.",
124
124
  metadata: { topic: "clustering" },
125
125
  },
126
126
  {
@@ -220,9 +220,13 @@ const SEED_DOCS = [
220
220
  metadata: { topic: "agent-tools-v2" },
221
221
  },
222
222
  {
223
- text: "Server problem categories the agent can fix: CODE BUGS (SyntaxError, TypeError, ReferenceError → edit_file), DEPENDENCIES (Cannot find module → npm install, corrupted node_modules → rm + reinstall), DATABASE (invalid entries → run_db_fix UPDATE, missing table → CREATE TABLE, schema mismatch → ALTER TABLE, constraint violation → fix data or schema), CONFIG (invalid JSON → edit_file, missing env vars → write .env, wrong port → edit config), FILESYSTEM (misplaced files → move_file, missing directories → bash_exec mkdir, wrong permissions → chmod), NETWORK (port conflict → check_port + kill, service down → restart, connection refused → check config), STATE (corrupted cache → delete + restart, stale locks → remove lock file, git conflicts → resolve markers). The agent investigates before fixing — reads files, checks directories, inspects databases, never guesses.",
223
+ text: "Server problem categories the agent can fix: CODE BUGS (SyntaxError, TypeError, ReferenceError → edit_file), DEPENDENCIES (Cannot find module → npm install, corrupted node_modules → rm + reinstall), DATABASE (invalid entries → run_db_fix UPDATE, missing table → CREATE TABLE, schema mismatch → ALTER TABLE, constraint violation → fix data or schema), CONFIG (invalid JSON → edit_file, missing env vars → write .env, wrong port → edit config), FILESYSTEM (misplaced files → move_file, missing directories → bash_exec mkdir, wrong permissions → chmod), NETWORK (port conflict → check_port + kill, service down → restart, connection refused → check config), STATE (corrupted cache → delete + restart, stale locks → remove lock file, git conflicts → resolve markers), IDEMPOTENCY (double-fire → add idempotencyGuard middleware, missing idempotency key → add X-Idempotency-Key header support, duplicate DB entries → add UNIQUE constraint or use db.idempotent()). The agent investigates before fixing — reads files, checks directories, inspects databases, never guesses.",
224
224
  metadata: { topic: "server-problems" },
225
225
  },
226
+ {
227
+ text: "Idempotency protection: two layers prevent double-fire in cluster mode. Layer 1: idempotencyGuard() Fastify middleware — intercepts POST/PUT/PATCH/DELETE, checks X-Idempotency-Key header (or auto-generates key from method+path+body hash), queries _idempotency table. If key exists and not expired → return cached response with X-Idempotency-Cached:true header, skip handler. If new → pass through, idempotencyAfterHook() stores response. Layer 2: SafeDB.idempotent(key, fn) — database-level dedup. Wraps fn in transaction, checks key, executes only if new. Returns {executed:true/false, result, cached}. Keys expire after TTL (default 24h). All workers share the SQLite _idempotency table via WAL mode — globally consistent. Auto-pruned on connect and via db.pruneIdempotency().",
228
+ metadata: { topic: "idempotency" },
229
+ },
226
230
  {
227
231
  text: "Heal pipeline no longer requires a file path. When no file is identified from the error (database errors, config problems, port conflicts), the pipeline skips fast path and goes straight to the agent, which uses investigation tools (glob_files, grep_code, list_dir, inspect_db, check_env, check_port) to find the root cause. Agent verification for no-file errors: if agent made changes or ran commands, trust the agent's assessment. For file-based errors, verification uses syntax check + boot probe as before.",
228
232
  metadata: { topic: "fileless-heal" },
package/src/index.js CHANGED
@@ -33,7 +33,7 @@ const { scanProject } = require("./brain/function-map");
33
33
  const { detect: detectSystem } = require("./core/system-info");
34
34
  const { ClusterManager } = require("./core/cluster-manager");
35
35
  const { loadConfig, getConfig } = require("./core/config");
36
- const { sqlGuard, SafeDB, scanForInjection } = require("./skills/sql");
36
+ const { sqlGuard, SafeDB, scanForInjection, idempotencyGuard, idempotencyAfterHook } = require("./skills/sql");
37
37
 
38
38
  module.exports = {
39
39
  // Core
@@ -93,4 +93,6 @@ module.exports = {
93
93
  sqlGuard,
94
94
  SafeDB,
95
95
  scanForInjection,
96
+ idempotencyGuard,
97
+ idempotencyAfterHook,
96
98
  };
package/src/skills/sql.js CHANGED
@@ -201,6 +201,18 @@ class SafeDB {
201
201
  this._writer.pragma("foreign_keys = ON");
202
202
  this._writer.pragma("synchronous = NORMAL");
203
203
 
204
+ // Idempotency table — prevents double-execution of writes in cluster mode
205
+ this._writer.exec(`
206
+ CREATE TABLE IF NOT EXISTS _idempotency (
207
+ key TEXT PRIMARY KEY,
208
+ result TEXT,
209
+ created_at INTEGER DEFAULT (strftime('%s','now')),
210
+ expires_at INTEGER
211
+ )
212
+ `);
213
+ // Clean expired keys on connect
214
+ this._writer.exec(`DELETE FROM _idempotency WHERE expires_at < strftime('%s','now')`);
215
+
204
216
  } catch (err) {
205
217
  if (err.code === "MODULE_NOT_FOUND") {
206
218
  throw new Error("Install better-sqlite3: npm install better-sqlite3");
@@ -216,6 +228,49 @@ class SafeDB {
216
228
  process.on("exit", () => this.close());
217
229
  }
218
230
 
231
+ /**
232
+ * Idempotent write — execute fn only if this key hasn't been seen before.
233
+ * In cluster mode, prevents the same request from double-firing across workers.
234
+ *
235
+ * @param {string} key — unique idempotency key (e.g. from X-Idempotency-Key header)
236
+ * @param {Function} fn — function that performs the write, receives writerProxy
237
+ * @param {number} ttlSeconds — how long to remember this key (default: 86400 = 24h)
238
+ * @returns {{ executed: boolean, result: any }} — executed=false if key was already seen
239
+ */
240
+ idempotent(key, fn, ttlSeconds = 86400) {
241
+ this._assertOpen();
242
+ return this._enqueueWrite(() => {
243
+ // Check if key already executed
244
+ const existing = this._writer.prepare("SELECT result FROM _idempotency WHERE key = ? AND expires_at > strftime('%s','now')").get(key);
245
+ if (existing) {
246
+ return { executed: false, result: JSON.parse(existing.result || "null"), cached: true };
247
+ }
248
+
249
+ // Execute the write
250
+ const txn = this._writer.transaction(() => {
251
+ const result = fn(this._writerProxy());
252
+ // Store the key so duplicates are rejected
253
+ this._writer.prepare(
254
+ "INSERT OR REPLACE INTO _idempotency (key, result, expires_at) VALUES (?, ?, strftime('%s','now') + ?)"
255
+ ).run(key, JSON.stringify(result ?? null), ttlSeconds);
256
+ return result;
257
+ });
258
+
259
+ const result = txn();
260
+ return { executed: true, result, cached: false };
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Clean up expired idempotency keys. Call periodically (e.g., every hour).
266
+ */
267
+ pruneIdempotency() {
268
+ this._assertOpen();
269
+ return this._enqueueWrite(() => {
270
+ return this._writer.prepare("DELETE FROM _idempotency WHERE expires_at < strftime('%s','now')").run();
271
+ });
272
+ }
273
+
219
274
  /**
220
275
  * Write query (INSERT, UPDATE, DELETE, CREATE).
221
276
  * Queued and executed in order. Returns a promise that resolves with the result.
@@ -327,27 +382,137 @@ class SafeDB {
327
382
  }
328
383
  }
329
384
 
385
+ // ── Idempotency Middleware ──────────────────────────────────────
386
+
387
+ /**
388
+ * Request idempotency middleware — prevents double-fire in cluster mode.
389
+ *
390
+ * How it works:
391
+ * 1. Client sends write request (POST/PUT/PATCH/DELETE) with X-Idempotency-Key header
392
+ * 2. Middleware checks if this key was already processed
393
+ * 3. If yes: return cached response (no re-execution)
394
+ * 4. If no: execute handler, cache response, return result
395
+ *
396
+ * Without the header, mutating requests get an auto-generated key based on
397
+ * method + path + body hash. This means identical retries are deduplicated
398
+ * even without client cooperation.
399
+ *
400
+ * In cluster mode (reusePort), a retry can land on a different worker.
401
+ * Since all workers share the same SQLite database (WAL mode), the
402
+ * idempotency table is visible to all workers instantly.
403
+ *
404
+ * Safe methods (GET, HEAD, OPTIONS) are always passed through — they're
405
+ * inherently idempotent.
406
+ *
407
+ * @param {object} options
408
+ * @param {SafeDB} options.db — SafeDB instance (must be connected)
409
+ * @param {number} options.ttlSeconds — how long to cache responses (default: 86400)
410
+ * @param {object} options.logger — wolverine EventLogger (optional)
411
+ */
412
+ function idempotencyGuard(options = {}) {
413
+ const db = options.db;
414
+ const ttlSeconds = options.ttlSeconds || 86400;
415
+ const logger = options.logger || null;
416
+ const crypto = require("crypto");
417
+
418
+ return async (req, res, next) => {
419
+ // Safe methods are inherently idempotent — pass through
420
+ const method = (req.method || "GET").toUpperCase();
421
+ if (["GET", "HEAD", "OPTIONS"].includes(method)) return next();
422
+
423
+ // Get or generate idempotency key
424
+ let key = req.headers["x-idempotency-key"] || req.headers["idempotency-key"];
425
+ if (!key) {
426
+ // Auto-generate from method + path + body hash
427
+ const bodyStr = typeof req.body === "string" ? req.body : JSON.stringify(req.body || "");
428
+ key = crypto.createHash("sha256").update(`${method}:${req.url}:${bodyStr}`).digest("hex");
429
+ }
430
+
431
+ if (!db || !db._writer) return next(); // No DB — can't check, pass through
432
+
433
+ try {
434
+ // Check idempotency table directly (read from writer for consistency)
435
+ const existing = db._writer.prepare(
436
+ "SELECT result FROM _idempotency WHERE key = ? AND expires_at > strftime('%s','now')"
437
+ ).get(key);
438
+
439
+ if (existing) {
440
+ // Already processed — return cached response
441
+ const cached = JSON.parse(existing.result || "null");
442
+ if (logger) logger.debug("idempotency.hit", `Duplicate request blocked: ${method} ${req.url}`, { key: key.slice(0, 16) });
443
+
444
+ const status = cached?.statusCode || 200;
445
+ const body = cached?.body || cached;
446
+ if (typeof res.code === "function") {
447
+ // Fastify
448
+ res.code(status).header("X-Idempotency-Cached", "true").send(body);
449
+ } else {
450
+ // Express
451
+ res.status(status).set("X-Idempotency-Cached", "true").json(body);
452
+ }
453
+ return;
454
+ }
455
+
456
+ // Not seen — attach key to request for the route handler to use
457
+ req._idempotencyKey = key;
458
+ req._idempotencyTtl = ttlSeconds;
459
+ } catch {
460
+ // DB error — don't block the request, just pass through
461
+ }
462
+
463
+ next();
464
+ };
465
+ }
466
+
467
+ /**
468
+ * After-response hook — stores the response for future idempotency checks.
469
+ * For Fastify, add as onSend hook. For Express, monkey-patch res.json.
470
+ *
471
+ * @param {SafeDB} db — connected SafeDB instance
472
+ */
473
+ function idempotencyAfterHook(db) {
474
+ return (req, reply, payload, done) => {
475
+ if (req._idempotencyKey && db && db._writer) {
476
+ try {
477
+ const statusCode = reply.statusCode || 200;
478
+ const result = JSON.stringify({ statusCode, body: typeof payload === "string" ? JSON.parse(payload) : payload });
479
+ db._writer.prepare(
480
+ "INSERT OR IGNORE INTO _idempotency (key, result, expires_at) VALUES (?, ?, strftime('%s','now') + ?)"
481
+ ).run(req._idempotencyKey, result, req._idempotencyTtl || 86400);
482
+ } catch { /* non-fatal */ }
483
+ }
484
+ done();
485
+ };
486
+ }
487
+
330
488
  // ── Skill Metadata (for SkillRegistry discovery) ──
331
489
 
332
490
  const SKILL_NAME = "sql";
333
- const SKILL_DESCRIPTION = "SQL database interface with injection prevention. Provides sqlGuard() middleware to block SQL injection on all endpoints, and SafeDB class for parameterized-only database queries.";
334
- const SKILL_KEYWORDS = ["sql", "database", "db", "query", "injection", "sqlite", "postgres", "mysql", "select", "insert", "update", "delete", "table", "schema", "migration", "parameterized"];
335
- const SKILL_USAGE = `// Protect all routes from SQL injection
336
- const { sqlGuard } = require("../src/skills/sql");
337
- app.use(sqlGuard({ logger: wolverineLogger }));
338
-
339
- // Cluster-safe database (each worker gets its own connection)
340
- const { SafeDB } = require("../src/skills/sql");
491
+ const SKILL_DESCRIPTION = "SQL database interface with injection prevention + idempotency. Provides sqlGuard() middleware to block SQL injection, idempotencyGuard() middleware to prevent double-fire in cluster mode, and SafeDB class for parameterized-only database queries with built-in idempotency key support.";
492
+ const SKILL_KEYWORDS = ["sql", "database", "db", "query", "injection", "sqlite", "postgres", "mysql", "select", "insert", "update", "delete", "table", "schema", "migration", "parameterized", "idempotent", "idempotency", "duplicate", "double", "cluster", "transaction"];
493
+ const SKILL_USAGE = `// Protect routes from SQL injection + double-fire
494
+ const { sqlGuard, idempotencyGuard, idempotencyAfterHook, SafeDB } = require("../src/skills/sql");
341
495
  const db = new SafeDB({ type: "sqlite", path: "./server/data.db" });
342
- await db.connect(); // WAL mode, busy_timeout=5s, write serialization
496
+ await db.connect();
497
+
498
+ // Middleware: injection prevention + idempotency (cluster-safe)
499
+ fastify.addHook("preHandler", sqlGuard({ logger }));
500
+ fastify.addHook("preHandler", idempotencyGuard({ db, logger }));
501
+ fastify.addHook("onSend", idempotencyAfterHook(db));
343
502
 
344
- // Reads (concurrent across workers)
503
+ // Reads (concurrent across workers — never waits)
345
504
  const users = db.all("SELECT * FROM users WHERE role = ?", ["admin"]);
346
505
 
347
- // Writes (serialized — no corruption)
506
+ // Writes (serialized FIFO queue — no corruption)
348
507
  db.run("INSERT INTO users (name, role) VALUES (?, ?)", ["Alice", "admin"]);
349
508
 
350
- // Batch writes (atomic transaction, single lock)
509
+ // Idempotent write prevents double-charge/double-insert in cluster mode
510
+ const result = await db.idempotent("order-abc-123", (tx) => {
511
+ tx.run("INSERT INTO orders (id, total) VALUES (?, ?)", ["abc-123", 99.99]);
512
+ return { orderId: "abc-123" };
513
+ }); // result.executed=true first time, false on retry
514
+
515
+ // Atomic transaction (all-or-nothing)
351
516
  db.transaction((tx) => {
352
517
  tx.run("INSERT INTO orders (user_id, total) VALUES (?, ?)", [1, 99.99]);
353
518
  tx.run("UPDATE users SET order_count = order_count + 1 WHERE id = ?", [1]);
@@ -364,6 +529,8 @@ module.exports = {
364
529
 
365
530
  // Middleware
366
531
  sqlGuard,
532
+ idempotencyGuard,
533
+ idempotencyAfterHook,
367
534
  scanForInjection,
368
535
  deepScan,
369
536