@ismaelsoilet/synapsejs 1.3.0 → 1.8.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.
Files changed (38) hide show
  1. package/README.md +15 -8
  2. package/bin/synapse.ts +70 -3
  3. package/package.json +1 -1
  4. package/src/client/components.tsx +26 -9
  5. package/src/client/outlet.tsx +40 -0
  6. package/src/client/sub-slice.tsx +45 -0
  7. package/src/client/use-websocket.ts +146 -0
  8. package/src/client.ts +12 -0
  9. package/src/compiler/client-bundler.ts +17 -7
  10. package/src/compiler/migration-runner.ts +134 -9
  11. package/src/compiler/scaffolder.ts +62 -0
  12. package/src/compiler/schema-dag.ts +18 -11
  13. package/src/compiler/slice-splitter.ts +29 -4
  14. package/src/compiler/slice-templates.ts +10 -0
  15. package/src/compiler/standalone-builder.ts +16 -2
  16. package/src/core/action-context.ts +56 -0
  17. package/src/core/action-definition.ts +89 -0
  18. package/src/core/cache.ts +139 -0
  19. package/src/core/config.ts +37 -1
  20. package/src/core/index.ts +3 -0
  21. package/src/core/postgres-client.ts +6 -0
  22. package/src/core/postgres-queue.ts +27 -7
  23. package/src/core/query-builder.ts +75 -60
  24. package/src/core/session-context.ts +1 -1
  25. package/src/core/session-token.ts +7 -1
  26. package/src/core/sqlite-client.ts +26 -1
  27. package/src/core/storage.ts +1 -5
  28. package/src/core/websocket.ts +30 -0
  29. package/src/index.ts +8 -1
  30. package/src/mcp/server.ts +63 -1
  31. package/src/runtime/discovery-rules.ts +20 -1
  32. package/src/runtime/event-hub.ts +6 -1
  33. package/src/runtime/network-guard.ts +180 -0
  34. package/src/runtime/queue-engine.ts +55 -21
  35. package/src/runtime/rate-limiter.ts +14 -0
  36. package/src/runtime/server.ts +572 -139
  37. package/src/version.ts +4 -0
  38. package/templates/starter/.jev.json +1 -1
package/src/mcp/server.ts CHANGED
@@ -8,9 +8,13 @@
8
8
 
9
9
  import {
10
10
  type AbortGateResult,
11
+ type ModelRouteResult,
11
12
  modulateReasoningEffort,
13
+ type NudgeGateResult,
12
14
  type ReasoningEffortResult,
15
+ routeModelTier,
13
16
  shouldAbortTrajectory,
17
+ shouldNudgeContinuation,
14
18
  type TestTriageResult,
15
19
  triageTestFailure,
16
20
  type VerificationResult,
@@ -28,6 +32,7 @@ import { type SliceTemplate, scaffoldCrud, scaffoldSlice } from '../compiler/sca
28
32
  import { checkSchemaDrift } from '../compiler/schema-drift';
29
33
  import { findSliceFiles, resolveSlicesDir } from '../compiler/slice-discovery';
30
34
  import { artifactDirectory, splitSlice, verifySplit, writeSplitArtifacts } from '../compiler/slice-splitter';
35
+ import { SYNAPSE_VERSION } from '../version';
31
36
 
32
37
  interface JsonRpcRequest {
33
38
  jsonrpc: string;
@@ -90,7 +95,7 @@ export class SynapseMcpServer {
90
95
  },
91
96
  serverInfo: {
92
97
  name: 'synapse-mcp',
93
- version: '1.2.0'
98
+ version: SYNAPSE_VERSION
94
99
  }
95
100
  }
96
101
  };
@@ -272,6 +277,51 @@ export class SynapseMcpServer {
272
277
  }
273
278
  }
274
279
  }
280
+ },
281
+ {
282
+ name: 'synapse_triage_error',
283
+ description:
284
+ 'Triage an arbitrary test failure or execution error log using Jev System One to classify root causes (env_missing vs flaky_transient vs deep_logic) and avoid token waste',
285
+ inputSchema: {
286
+ type: 'object',
287
+ required: ['failureLog'],
288
+ properties: {
289
+ failureLog: {
290
+ type: 'string',
291
+ description: 'Error log, stack trace or terminal output to triage'
292
+ }
293
+ }
294
+ }
295
+ },
296
+ {
297
+ name: 'synapse_route_task',
298
+ description:
299
+ 'Semantically route a development task to the optimal model/reasoning tier via Jev System One',
300
+ inputSchema: {
301
+ type: 'object',
302
+ required: ['task'],
303
+ properties: {
304
+ task: {
305
+ type: 'string',
306
+ description: 'Description of the coding, refactoring, or diagnostic task'
307
+ }
308
+ }
309
+ }
310
+ },
311
+ {
312
+ name: 'synapse_evaluate_nudge',
313
+ description:
314
+ 'Evaluate whether an agent is prematurely stopping or needs a proactive nudge to continue verification',
315
+ inputSchema: {
316
+ type: 'object',
317
+ required: ['transcriptTail'],
318
+ properties: {
319
+ transcriptTail: {
320
+ type: 'string',
321
+ description: 'Recent transcript steps or conversation history tail'
322
+ }
323
+ }
324
+ }
275
325
  }
276
326
  ]
277
327
  }
@@ -597,6 +647,18 @@ export class SynapseMcpServer {
597
647
  sessionContextTokens
598
648
  });
599
649
  contentText = JSON.stringify(result, null, 2);
650
+ } else if (toolName === 'synapse_triage_error') {
651
+ const failureLog = String(args.failureLog || '');
652
+ const result: TestTriageResult = await triageTestFailure(failureLog);
653
+ contentText = JSON.stringify(result, null, 2);
654
+ } else if (toolName === 'synapse_route_task') {
655
+ const task = String(args.task || '');
656
+ const result: ModelRouteResult = await routeModelTier(task);
657
+ contentText = JSON.stringify(result, null, 2);
658
+ } else if (toolName === 'synapse_evaluate_nudge') {
659
+ const transcriptTail = String(args.transcriptTail || '');
660
+ const result: NudgeGateResult = await shouldNudgeContinuation(transcriptTail);
661
+ contentText = JSON.stringify(result, null, 2);
600
662
  } else {
601
663
  return {
602
664
  jsonrpc: '2.0',
@@ -13,7 +13,11 @@ export const WEBHOOK_SUFFIX = 'Webhook';
13
13
  export const LOADER_SUFFIX = 'Loader';
14
14
  export const SCHEMA_SUFFIX = 'Schema';
15
15
  export const META_SUFFIX = 'Meta';
16
+ export const CACHE_SUFFIX = 'Cache';
17
+ export const SOCKET_SUFFIX = 'Socket';
16
18
  export const SLICE_META_EXPORT = 'sliceMeta';
19
+ export const SLICE_CACHE_EXPORT = 'sliceCache';
20
+ export const SLICE_SOCKET_EXPORT = 'sliceSocket';
17
21
  export const SLICE_DDL_EXPORT = 'sliceSchema';
18
22
  export const SLICE_ORACLE_EXPORT = 'sliceTests';
19
23
 
@@ -21,7 +25,14 @@ export const SLICE_ORACLE_EXPORT = 'sliceTests';
21
25
  export const COMPONENT_SUFFIXES = ['Trigger', 'View', 'Form', 'Component'] as const;
22
26
 
23
27
  /** Everything the server side of a slice is built from. */
24
- export const SERVER_ROOT_SUFFIXES = [ACTION_SUFFIX, JOB_SUFFIX, WEBHOOK_SUFFIX, META_SUFFIX] as const;
28
+ export const SERVER_ROOT_SUFFIXES = [
29
+ ACTION_SUFFIX,
30
+ JOB_SUFFIX,
31
+ WEBHOOK_SUFFIX,
32
+ META_SUFFIX,
33
+ CACHE_SUFFIX,
34
+ SOCKET_SUFFIX
35
+ ] as const;
25
36
 
26
37
  /** Test-only exports, dropped from both runtime bundles. */
27
38
  export const TEST_ONLY_EXPORTS = [SLICE_ORACLE_EXPORT] as const;
@@ -58,6 +69,14 @@ export function isMeta(name: string): boolean {
58
69
  return name === SLICE_META_EXPORT || name.endsWith(META_SUFFIX);
59
70
  }
60
71
 
72
+ export function isCache(name: string): boolean {
73
+ return name === SLICE_CACHE_EXPORT || name.endsWith(CACHE_SUFFIX);
74
+ }
75
+
76
+ export function isSocket(name: string): boolean {
77
+ return name === SLICE_SOCKET_EXPORT || name.endsWith(SOCKET_SUFFIX);
78
+ }
79
+
61
80
  export function isTestOnly(name: string): boolean {
62
81
  return hasSuffix(name, TEST_ONLY_EXPORTS);
63
82
  }
@@ -46,7 +46,12 @@ export class EventHub {
46
46
  let notified = 0;
47
47
  for (const listener of listeners) {
48
48
  try {
49
- listener(data);
49
+ const result: unknown = listener(data);
50
+ if (result && typeof (result as { catch?: unknown }).catch === 'function') {
51
+ (result as { catch: (fn: (err: unknown) => void) => void }).catch((err: unknown) => {
52
+ console.error(`[EventHub] Erro assíncrono no listener do tópico "${topic}":`, err);
53
+ });
54
+ }
50
55
  notified++;
51
56
  } catch (err) {
52
57
  console.error(`[EventHub] Erro no listener do tópico "${topic}":`, err);
@@ -0,0 +1,180 @@
1
+ import dns from 'node:dns';
2
+ import net from 'node:net';
3
+
4
+ /**
5
+ * Checks whether an IP address (IPv4 or IPv6) belongs to a private, loopback,
6
+ * link-local, or cloud-provider metadata address space.
7
+ */
8
+ export function isPrivateOrReservedIp(ip: string): boolean {
9
+ const version = net.isIP(ip);
10
+ if (version === 0) {
11
+ return false;
12
+ }
13
+
14
+ if (version === 4) {
15
+ const parts = ip.split('.').map((p) => parseInt(p, 10));
16
+ if (parts.length !== 4 || parts.some(Number.isNaN)) return true;
17
+
18
+ const [a, b] = parts;
19
+
20
+ // 0.0.0.0/8 (Broadcast/Current network)
21
+ if (a === 0) return true;
22
+
23
+ // 10.0.0.0/8 (Private)
24
+ if (a === 10) return true;
25
+
26
+ // 100.64.0.0/10 (Carrier-grade NAT)
27
+ if (a === 100 && b >= 64 && b <= 127) return true;
28
+
29
+ // 127.0.0.0/8 (Loopback)
30
+ if (a === 127) return true;
31
+
32
+ // 169.254.0.0/16 (Link-local & Cloud Metadata: AWS, GCP, Azure, DO)
33
+ if (a === 169 && b === 254) return true;
34
+
35
+ // 172.16.0.0/12 (Private)
36
+ if (a === 172 && b >= 16 && b <= 31) return true;
37
+
38
+ // 192.0.0.0/24 (IETF Protocol Assignments)
39
+ // 192.0.2.0/24 (TEST-NET-1)
40
+ if (a === 192 && b === 0) return true;
41
+
42
+ // 192.168.0.0/16 (Private)
43
+ if (a === 192 && b === 168) return true;
44
+
45
+ // 198.18.0.0/15 (Benchmarking)
46
+ if (a === 198 && (b === 18 || b === 19)) return true;
47
+
48
+ // 198.51.100.0/24 (TEST-NET-2)
49
+ if (a === 198 && b === 51) return true;
50
+
51
+ // 203.0.113.0/24 (TEST-NET-3)
52
+ if (a === 203 && b === 0) return true;
53
+
54
+ // 224.0.0.0/4 (Multicast) & 240.0.0.0/4 (Reserved)
55
+ if (a >= 224) return true;
56
+
57
+ return false;
58
+ }
59
+
60
+ // IPv6 checks
61
+ const normalized = ip.toLowerCase();
62
+
63
+ // ::1 (Loopback)
64
+ if (normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') return true;
65
+
66
+ // :: (Unspecified)
67
+ if (normalized === '::' || normalized === '0:0:0:0:0:0:0:0') return true;
68
+
69
+ // IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
70
+ if (normalized.startsWith('::ffff:')) {
71
+ const v4Part = normalized.slice(7);
72
+ if (net.isIPv4(v4Part)) {
73
+ return isPrivateOrReservedIp(v4Part);
74
+ }
75
+ }
76
+
77
+ // fc00::/7 (Unique Local Address ULA)
78
+ if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
79
+
80
+ // fe80::/10 (Link-Local Unicast)
81
+ if (/^fe[89ab]/.test(normalized)) return true;
82
+
83
+ // ff00::/8 (Multicast)
84
+ if (normalized.startsWith('ff')) return true;
85
+
86
+ return false;
87
+ }
88
+
89
+ export interface ValidateUrlOptions {
90
+ allowedDomains?: string[];
91
+ allowLocalhost?: boolean;
92
+ }
93
+
94
+ export interface ValidateUrlResult {
95
+ ok: boolean;
96
+ error?: string;
97
+ resolvedIp?: string;
98
+ }
99
+
100
+ /**
101
+ * Validates a remote URL against SSRF vulnerabilities:
102
+ * - Scheme must be http or https
103
+ * - Hostname cannot be localhost or known cloud metadata domains (unless allowLocalhost is true)
104
+ * - Domain must match allowedDomains list if specified
105
+ * - Pre-resolves DNS to ensure target IP is not private, loopback, or cloud metadata
106
+ */
107
+ export async function validateExternalUrl(
108
+ rawUrl: string,
109
+ options: ValidateUrlOptions = {}
110
+ ): Promise<ValidateUrlResult> {
111
+ let parsed: URL;
112
+ try {
113
+ parsed = new URL(rawUrl);
114
+ } catch {
115
+ return { ok: false, error: 'INVALID_URL' };
116
+ }
117
+
118
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
119
+ return { ok: false, error: 'INVALID_PROTOCOL' };
120
+ }
121
+
122
+ const hostname = parsed.hostname.toLowerCase();
123
+
124
+ // Disallow user/password in URL for SSRF protection
125
+ if (parsed.username || parsed.password) {
126
+ return { ok: false, error: 'CREDENTIALS_IN_URL_FORBIDDEN' };
127
+ }
128
+
129
+ // Common metadata and internal hosts
130
+ if (
131
+ hostname === 'metadata.google.internal' ||
132
+ hostname === 'instance-data' ||
133
+ hostname.endsWith('.internal') ||
134
+ hostname.endsWith('.local')
135
+ ) {
136
+ return { ok: false, error: 'FORBIDDEN_METADATA_HOST' };
137
+ }
138
+
139
+ if (!options.allowLocalhost && (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1')) {
140
+ return { ok: false, error: 'LOOPBACK_FORBIDDEN' };
141
+ }
142
+
143
+ // Allowed domains enforcement
144
+ if (options.allowedDomains && options.allowedDomains.length > 0) {
145
+ const isAllowed = options.allowedDomains.some((d) => {
146
+ const dom = d.toLowerCase().trim();
147
+ return hostname === dom || hostname.endsWith(`.${dom}`);
148
+ });
149
+ if (!isAllowed) {
150
+ return { ok: false, error: 'DOMAIN_NOT_ALLOWED' };
151
+ }
152
+ }
153
+
154
+ // If hostname is directly an IP literal
155
+ if (net.isIP(hostname)) {
156
+ if (!options.allowLocalhost && isPrivateOrReservedIp(hostname)) {
157
+ return { ok: false, error: 'FORBIDDEN_TARGET_IP', resolvedIp: hostname };
158
+ }
159
+ return { ok: true, resolvedIp: hostname };
160
+ }
161
+
162
+ // DNS Pre-check to prevent DNS rebinding
163
+ try {
164
+ const records = await dns.promises.lookup(hostname, { all: true });
165
+ if (!records || records.length === 0) {
166
+ return { ok: false, error: 'DNS_LOOKUP_EMPTY' };
167
+ }
168
+
169
+ for (const record of records) {
170
+ if (!options.allowLocalhost && isPrivateOrReservedIp(record.address)) {
171
+ return { ok: false, error: 'FORBIDDEN_TARGET_IP', resolvedIp: record.address };
172
+ }
173
+ }
174
+
175
+ return { ok: true, resolvedIp: records[0].address };
176
+ } catch (err: unknown) {
177
+ const message = err instanceof Error ? err.message : String(err);
178
+ return { ok: false, error: `DNS_LOOKUP_FAILED: ${message}` };
179
+ }
180
+ }
@@ -31,6 +31,7 @@ export interface JobRecord {
31
31
  run_at: number;
32
32
  created_at: number;
33
33
  updated_at: number;
34
+ locked_at?: number | null;
34
35
  last_error: string | null;
35
36
  }
36
37
 
@@ -38,15 +39,23 @@ export interface QueueEngineOptions {
38
39
  /** Path to SQLite database file or ':memory:' */
39
40
  dbPath?: string;
40
41
  logger?: StructuredLogger;
42
+ /** Inactivity window in ms before a 'running' job without heartbeat is recovered (default: 300_000ms = 5m) */
43
+ visibilityTimeoutMs?: number;
44
+ /** Inactivity window in seconds (alternative to visibilityTimeoutMs) */
45
+ visibilityTimeoutSeconds?: number;
41
46
  }
42
47
 
43
48
  export class QueueEngine {
44
49
  private db: Database;
45
50
  private registry: Map<string, JobDefinition<unknown>> = new Map();
46
51
  private logger: StructuredLogger;
52
+ private visibilityTimeoutMs: number;
47
53
 
48
54
  constructor(options: QueueEngineOptions = {}) {
49
55
  this.logger = options.logger || createDefaultLogger('QueueEngine');
56
+ this.visibilityTimeoutMs =
57
+ options.visibilityTimeoutMs ??
58
+ (options.visibilityTimeoutSeconds !== undefined ? options.visibilityTimeoutSeconds * 1000 : 300_000);
50
59
  const dbPath = options.dbPath || path.join(process.cwd(), '.synapse/queue.sqlite');
51
60
 
52
61
  if (dbPath !== ':memory:') {
@@ -80,11 +89,22 @@ export class QueueEngine {
80
89
  run_at INTEGER NOT NULL,
81
90
  created_at INTEGER NOT NULL,
82
91
  updated_at INTEGER NOT NULL,
92
+ locked_at INTEGER,
83
93
  last_error TEXT
84
94
  );
85
95
  CREATE INDEX IF NOT EXISTS idx_synapse_jobs_pending
86
96
  ON _synapse_jobs (status, run_at, priority DESC);
87
97
  `);
98
+ try {
99
+ this.db.run('ALTER TABLE _synapse_jobs ADD COLUMN locked_at INTEGER;');
100
+ } catch {
101
+ // Column already exists
102
+ }
103
+ try {
104
+ this.db.run('CREATE INDEX IF NOT EXISTS idx_synapse_jobs_stale ON _synapse_jobs (status, locked_at);');
105
+ } catch {
106
+ // Index already exists
107
+ }
88
108
  }
89
109
 
90
110
  /**
@@ -123,23 +143,37 @@ export class QueueEngine {
123
143
  }
124
144
 
125
145
  /**
126
- * Atomically claims the next pending job eligible for execution.
146
+ * Atomically claims the next pending job eligible for execution, or recovers
147
+ * any stale 'running' job whose worker has died (visibility timeout exceeded).
127
148
  */
128
- claimNextJob(): JobRecord | null {
149
+ claimNextJob(visibilityTimeoutMs?: number): JobRecord | null {
129
150
  const now = Date.now();
151
+ const timeout = visibilityTimeoutMs ?? this.visibilityTimeoutMs;
152
+ const staleThreshold = now - timeout;
153
+
154
+ // Fail expired zombie jobs that already exhausted max_attempts
155
+ this.db
156
+ .prepare(`
157
+ UPDATE _synapse_jobs
158
+ SET status = 'failed', locked_at = NULL, updated_at = ?1, last_error = 'Visibility timeout exceeded max attempts'
159
+ WHERE status = 'running' AND (locked_at IS NULL OR locked_at < ?2) AND attempts >= max_attempts
160
+ `)
161
+ .run(now, staleThreshold);
162
+
130
163
  const stmt = this.db.prepare(`
131
164
  UPDATE _synapse_jobs
132
- SET status = 'running', updated_at = ?1
165
+ SET status = 'running', locked_at = ?1, updated_at = ?1, attempts = attempts + 1
133
166
  WHERE id = (
134
167
  SELECT id FROM _synapse_jobs
135
- WHERE status = 'pending' AND run_at <= ?1
168
+ WHERE (status = 'pending' AND run_at <= ?1)
169
+ OR (status = 'running' AND (locked_at IS NULL OR locked_at < ?2) AND attempts < max_attempts)
136
170
  ORDER BY priority DESC, run_at ASC, created_at ASC
137
171
  LIMIT 1
138
172
  )
139
173
  RETURNING *;
140
174
  `);
141
175
 
142
- const row = stmt.get(now) as JobRecord | undefined;
176
+ const row = stmt.get(now, staleThreshold) as JobRecord | undefined;
143
177
  return row || null;
144
178
  }
145
179
 
@@ -158,7 +192,7 @@ export class QueueEngine {
158
192
  }
159
193
 
160
194
  const jobDef = this.registry.get(job.name);
161
- const attempts = job.attempts + 1;
195
+ const attempts = job.attempts;
162
196
  const now = Date.now();
163
197
 
164
198
  if (!jobDef) {
@@ -166,10 +200,10 @@ export class QueueEngine {
166
200
  this.logger.error(errorMsg);
167
201
  const updateStmt = this.db.prepare(`
168
202
  UPDATE _synapse_jobs
169
- SET status = 'failed', attempts = ?1, updated_at = ?2, last_error = ?3
170
- WHERE id = ?4
203
+ SET status = 'failed', locked_at = NULL, updated_at = ?1, last_error = ?2
204
+ WHERE id = ?3 AND (locked_at = ?4 OR locked_at IS NULL)
171
205
  `);
172
- updateStmt.run(attempts, now, errorMsg, job.id);
206
+ updateStmt.run(now, errorMsg, job.id, job.locked_at ?? null);
173
207
  return true;
174
208
  }
175
209
 
@@ -180,10 +214,10 @@ export class QueueEngine {
180
214
  const errorMsg = `Falha ao desserializar payload JSON do job: ${String(parseErr)}`;
181
215
  const updateStmt = this.db.prepare(`
182
216
  UPDATE _synapse_jobs
183
- SET status = 'failed', attempts = ?1, updated_at = ?2, last_error = ?3
184
- WHERE id = ?4
217
+ SET status = 'failed', locked_at = NULL, updated_at = ?1, last_error = ?2
218
+ WHERE id = ?3 AND (locked_at = ?4 OR locked_at IS NULL)
185
219
  `);
186
- updateStmt.run(attempts, now, errorMsg, job.id);
220
+ updateStmt.run(now, errorMsg, job.id, job.locked_at ?? null);
187
221
  return true;
188
222
  }
189
223
 
@@ -199,10 +233,10 @@ export class QueueEngine {
199
233
  await jobDef.handler(parsedPayload, actionCtx);
200
234
  const completeStmt = this.db.prepare(`
201
235
  UPDATE _synapse_jobs
202
- SET status = 'completed', attempts = ?1, updated_at = ?2, last_error = NULL
203
- WHERE id = ?3
236
+ SET status = 'completed', locked_at = NULL, updated_at = ?1, last_error = NULL
237
+ WHERE id = ?2 AND (locked_at = ?3 OR locked_at IS NULL)
204
238
  `);
205
- completeStmt.run(attempts, now, job.id);
239
+ completeStmt.run(now, job.id, job.locked_at ?? null);
206
240
  return true;
207
241
  } catch (handlerErr: unknown) {
208
242
  const errorMsg = handlerErr instanceof Error ? handlerErr.message : String(handlerErr);
@@ -214,17 +248,17 @@ export class QueueEngine {
214
248
  const nextRunAt = now + backoffMs;
215
249
  const retryStmt = this.db.prepare(`
216
250
  UPDATE _synapse_jobs
217
- SET status = 'pending', attempts = ?1, run_at = ?2, updated_at = ?3, last_error = ?4
218
- WHERE id = ?5
251
+ SET status = 'pending', locked_at = NULL, run_at = ?1, updated_at = ?2, last_error = ?3
252
+ WHERE id = ?4 AND (locked_at = ?5 OR locked_at IS NULL)
219
253
  `);
220
- retryStmt.run(attempts, nextRunAt, now, errorMsg, job.id);
254
+ retryStmt.run(nextRunAt, now, errorMsg, job.id, job.locked_at ?? null);
221
255
  } else {
222
256
  const failStmt = this.db.prepare(`
223
257
  UPDATE _synapse_jobs
224
- SET status = 'failed', attempts = ?1, updated_at = ?2, last_error = ?3
225
- WHERE id = ?4
258
+ SET status = 'failed', locked_at = NULL, updated_at = ?1, last_error = ?2
259
+ WHERE id = ?3 AND (locked_at = ?4 OR locked_at IS NULL)
226
260
  `);
227
- failStmt.run(attempts, now, errorMsg, job.id);
261
+ failStmt.run(now, errorMsg, job.id, job.locked_at ?? null);
228
262
  }
229
263
  return true;
230
264
  }
@@ -18,6 +18,8 @@ export interface RateLimitOptions {
18
18
  refillRate?: number;
19
19
  /** Inactivity window in ms before bucket is evicted (default: 60,000ms = 1m) */
20
20
  idleTimeoutMs?: number;
21
+ /** Maximum tracked buckets in memory before LRU eviction (default: 10,000) */
22
+ maxBuckets?: number;
21
23
  }
22
24
 
23
25
  export interface RateLimitResult {
@@ -37,6 +39,7 @@ export class TokenBucketRateLimiter {
37
39
  private capacity: number;
38
40
  private refillRate: number;
39
41
  private idleTimeoutMs: number;
42
+ private maxBuckets: number;
40
43
  private buckets: Map<string, Bucket> = new Map();
41
44
  private cleanupTimer: ReturnType<typeof setInterval> | null = null;
42
45
 
@@ -47,6 +50,7 @@ export class TokenBucketRateLimiter {
47
50
  this.refillRate = options.refillRate ?? (Number.isFinite(envRps) && envRps > 0 ? envRps : 100);
48
51
  this.capacity = options.capacity ?? (Number.isFinite(envBurst) && envBurst > 0 ? envBurst : 150);
49
52
  this.idleTimeoutMs = options.idleTimeoutMs ?? 60_000;
53
+ this.maxBuckets = options.maxBuckets ?? 10_000;
50
54
 
51
55
  // Run background prune of idle buckets every minute
52
56
  this.cleanupTimer = setInterval(() => this.pruneIdleBuckets(), this.idleTimeoutMs);
@@ -63,12 +67,22 @@ export class TokenBucketRateLimiter {
63
67
  let bucket = this.buckets.get(key);
64
68
 
65
69
  if (!bucket) {
70
+ if (this.buckets.size >= this.maxBuckets) {
71
+ const oldest = this.buckets.keys().next().value;
72
+ if (oldest !== undefined) {
73
+ this.buckets.delete(oldest);
74
+ }
75
+ }
66
76
  bucket = {
67
77
  tokens: this.capacity,
68
78
  lastRefill: now,
69
79
  lastAccess: now
70
80
  };
71
81
  this.buckets.set(key, bucket);
82
+ } else {
83
+ // Move to end to track recency (LRU)
84
+ this.buckets.delete(key);
85
+ this.buckets.set(key, bucket);
72
86
  }
73
87
 
74
88
  // Refill tokens based on elapsed time