@ismaelsoilet/synapsejs 1.4.0 → 1.9.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 (53) hide show
  1. package/README.md +108 -10
  2. package/bin/synapse.ts +263 -218
  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/context-bench.ts +7 -1
  11. package/src/compiler/contract.ts +31 -4
  12. package/src/compiler/db-schema-generator.ts +39 -8
  13. package/src/compiler/fields-parser.ts +28 -1
  14. package/src/compiler/impact-analyzer.ts +31 -1
  15. package/src/compiler/migration-runner.ts +144 -11
  16. package/src/compiler/optional-coverage.ts +137 -0
  17. package/src/compiler/oracle-runner.ts +8 -3
  18. package/src/compiler/scaffolder.ts +174 -3
  19. package/src/compiler/schema-dag.ts +6 -3
  20. package/src/compiler/slice-discovery.ts +8 -1
  21. package/src/compiler/slice-splitter.ts +67 -6
  22. package/src/compiler/slice-templates.ts +10 -0
  23. package/src/compiler/standalone-builder.ts +16 -2
  24. package/src/core/action-context.ts +101 -8
  25. package/src/core/cache.ts +139 -0
  26. package/src/core/config.ts +124 -10
  27. package/src/core/database-client.ts +68 -6
  28. package/src/core/index.ts +5 -0
  29. package/src/core/path-guard.ts +105 -0
  30. package/src/core/postgres-client.ts +6 -0
  31. package/src/core/postgres-queue.ts +45 -7
  32. package/src/core/query-builder.ts +75 -60
  33. package/src/core/session-context.ts +1 -1
  34. package/src/core/session-token.ts +115 -8
  35. package/src/core/sqlite-client.ts +26 -1
  36. package/src/core/storage.ts +61 -24
  37. package/src/core/topics.ts +84 -0
  38. package/src/core/webhook-signature.ts +151 -0
  39. package/src/core/websocket.ts +30 -0
  40. package/src/index.ts +10 -2
  41. package/src/mcp/server.ts +78 -173
  42. package/src/mcp/tools.ts +238 -0
  43. package/src/project-status.ts +330 -0
  44. package/src/runtime/discovery-rules.ts +20 -1
  45. package/src/runtime/event-hub.ts +6 -1
  46. package/src/runtime/network-guard.ts +182 -0
  47. package/src/runtime/queue-engine.ts +102 -21
  48. package/src/runtime/rate-limiter.ts +24 -0
  49. package/src/runtime/server.ts +1380 -251
  50. package/src/runtime/uploads.ts +199 -19
  51. package/src/version.ts +11 -0
  52. package/templates/starter/.jev.json +1 -1
  53. package/templates/starter/tsconfig.json +1 -0
@@ -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,25 @@ 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;
53
+ /** Set at the start of shutdown: no new claim is taken from that moment on. */
54
+ private claimingStopped = false;
47
55
 
48
56
  constructor(options: QueueEngineOptions = {}) {
49
57
  this.logger = options.logger || createDefaultLogger('QueueEngine');
58
+ this.visibilityTimeoutMs =
59
+ options.visibilityTimeoutMs ??
60
+ (options.visibilityTimeoutSeconds !== undefined ? options.visibilityTimeoutSeconds * 1000 : 300_000);
50
61
  const dbPath = options.dbPath || path.join(process.cwd(), '.synapse/queue.sqlite');
51
62
 
52
63
  if (dbPath !== ':memory:') {
@@ -80,11 +91,22 @@ export class QueueEngine {
80
91
  run_at INTEGER NOT NULL,
81
92
  created_at INTEGER NOT NULL,
82
93
  updated_at INTEGER NOT NULL,
94
+ locked_at INTEGER,
83
95
  last_error TEXT
84
96
  );
85
97
  CREATE INDEX IF NOT EXISTS idx_synapse_jobs_pending
86
98
  ON _synapse_jobs (status, run_at, priority DESC);
87
99
  `);
100
+ try {
101
+ this.db.run('ALTER TABLE _synapse_jobs ADD COLUMN locked_at INTEGER;');
102
+ } catch {
103
+ // Column already exists
104
+ }
105
+ try {
106
+ this.db.run('CREATE INDEX IF NOT EXISTS idx_synapse_jobs_stale ON _synapse_jobs (status, locked_at);');
107
+ } catch {
108
+ // Index already exists
109
+ }
88
110
  }
89
111
 
90
112
  /**
@@ -123,23 +145,82 @@ export class QueueEngine {
123
145
  }
124
146
 
125
147
  /**
126
- * Atomically claims the next pending job eligible for execution.
148
+ * Stops the engine from claiming any new work. Jobs enqueued by a request that
149
+ * was in flight remain pending for the next process, instead of being claimed
150
+ * during a shutdown that is about to close the database.
127
151
  */
128
- claimNextJob(): JobRecord | null {
152
+ stopClaiming(): void {
153
+ this.claimingStopped = true;
154
+ }
155
+
156
+ get isClaimingStopped(): boolean {
157
+ return this.claimingStopped;
158
+ }
159
+
160
+ /**
161
+ * Returns every job that is still marked running to the queue. The attempt
162
+ * counter is left exactly as the interrupted claim left it — releasing must not
163
+ * consume a second attempt for one claim — and a job that already reached its
164
+ * maximum is marked failed instead of being handed back for another run.
165
+ */
166
+ interruptRunningJobs(): { requeued: number; failed: number } {
129
167
  const now = Date.now();
168
+
169
+ const failed = this.db
170
+ .prepare(`
171
+ UPDATE _synapse_jobs
172
+ SET status = 'failed', locked_at = NULL, updated_at = ?1, last_error = 'Interrompido pelo encerramento do processo'
173
+ WHERE status = 'running' AND attempts >= max_attempts
174
+ `)
175
+ .run(now);
176
+
177
+ const requeued = this.db
178
+ .prepare(`
179
+ UPDATE _synapse_jobs
180
+ SET status = 'pending', locked_at = NULL, updated_at = ?1, last_error = 'Interrompido pelo encerramento do processo'
181
+ WHERE status = 'running' AND attempts < max_attempts
182
+ `)
183
+ .run(now);
184
+
185
+ return { requeued: Number(requeued.changes ?? 0), failed: Number(failed.changes ?? 0) };
186
+ }
187
+
188
+ /**
189
+ * Atomically claims the next pending job eligible for execution, or recovers
190
+ * any stale 'running' job whose worker has died (visibility timeout exceeded).
191
+ */
192
+ claimNextJob(visibilityTimeoutMs?: number): JobRecord | null {
193
+ if (this.claimingStopped) {
194
+ return null;
195
+ }
196
+
197
+ const now = Date.now();
198
+ const timeout = visibilityTimeoutMs ?? this.visibilityTimeoutMs;
199
+ const staleThreshold = now - timeout;
200
+
201
+ // Fail expired zombie jobs that already exhausted max_attempts
202
+ this.db
203
+ .prepare(`
204
+ UPDATE _synapse_jobs
205
+ SET status = 'failed', locked_at = NULL, updated_at = ?1, last_error = 'Visibility timeout exceeded max attempts'
206
+ WHERE status = 'running' AND (locked_at IS NULL OR locked_at < ?2) AND attempts >= max_attempts
207
+ `)
208
+ .run(now, staleThreshold);
209
+
130
210
  const stmt = this.db.prepare(`
131
211
  UPDATE _synapse_jobs
132
- SET status = 'running', updated_at = ?1
212
+ SET status = 'running', locked_at = ?1, updated_at = ?1, attempts = attempts + 1
133
213
  WHERE id = (
134
214
  SELECT id FROM _synapse_jobs
135
- WHERE status = 'pending' AND run_at <= ?1
215
+ WHERE (status = 'pending' AND run_at <= ?1)
216
+ OR (status = 'running' AND (locked_at IS NULL OR locked_at < ?2) AND attempts < max_attempts)
136
217
  ORDER BY priority DESC, run_at ASC, created_at ASC
137
218
  LIMIT 1
138
219
  )
139
220
  RETURNING *;
140
221
  `);
141
222
 
142
- const row = stmt.get(now) as JobRecord | undefined;
223
+ const row = stmt.get(now, staleThreshold) as JobRecord | undefined;
143
224
  return row || null;
144
225
  }
145
226
 
@@ -158,7 +239,7 @@ export class QueueEngine {
158
239
  }
159
240
 
160
241
  const jobDef = this.registry.get(job.name);
161
- const attempts = job.attempts + 1;
242
+ const attempts = job.attempts;
162
243
  const now = Date.now();
163
244
 
164
245
  if (!jobDef) {
@@ -166,10 +247,10 @@ export class QueueEngine {
166
247
  this.logger.error(errorMsg);
167
248
  const updateStmt = this.db.prepare(`
168
249
  UPDATE _synapse_jobs
169
- SET status = 'failed', attempts = ?1, updated_at = ?2, last_error = ?3
170
- WHERE id = ?4
250
+ SET status = 'failed', locked_at = NULL, updated_at = ?1, last_error = ?2
251
+ WHERE id = ?3 AND (locked_at = ?4 OR locked_at IS NULL)
171
252
  `);
172
- updateStmt.run(attempts, now, errorMsg, job.id);
253
+ updateStmt.run(now, errorMsg, job.id, job.locked_at ?? null);
173
254
  return true;
174
255
  }
175
256
 
@@ -180,10 +261,10 @@ export class QueueEngine {
180
261
  const errorMsg = `Falha ao desserializar payload JSON do job: ${String(parseErr)}`;
181
262
  const updateStmt = this.db.prepare(`
182
263
  UPDATE _synapse_jobs
183
- SET status = 'failed', attempts = ?1, updated_at = ?2, last_error = ?3
184
- WHERE id = ?4
264
+ SET status = 'failed', locked_at = NULL, updated_at = ?1, last_error = ?2
265
+ WHERE id = ?3 AND (locked_at = ?4 OR locked_at IS NULL)
185
266
  `);
186
- updateStmt.run(attempts, now, errorMsg, job.id);
267
+ updateStmt.run(now, errorMsg, job.id, job.locked_at ?? null);
187
268
  return true;
188
269
  }
189
270
 
@@ -199,10 +280,10 @@ export class QueueEngine {
199
280
  await jobDef.handler(parsedPayload, actionCtx);
200
281
  const completeStmt = this.db.prepare(`
201
282
  UPDATE _synapse_jobs
202
- SET status = 'completed', attempts = ?1, updated_at = ?2, last_error = NULL
203
- WHERE id = ?3
283
+ SET status = 'completed', locked_at = NULL, updated_at = ?1, last_error = NULL
284
+ WHERE id = ?2 AND (locked_at = ?3 OR locked_at IS NULL)
204
285
  `);
205
- completeStmt.run(attempts, now, job.id);
286
+ completeStmt.run(now, job.id, job.locked_at ?? null);
206
287
  return true;
207
288
  } catch (handlerErr: unknown) {
208
289
  const errorMsg = handlerErr instanceof Error ? handlerErr.message : String(handlerErr);
@@ -214,17 +295,17 @@ export class QueueEngine {
214
295
  const nextRunAt = now + backoffMs;
215
296
  const retryStmt = this.db.prepare(`
216
297
  UPDATE _synapse_jobs
217
- SET status = 'pending', attempts = ?1, run_at = ?2, updated_at = ?3, last_error = ?4
218
- WHERE id = ?5
298
+ SET status = 'pending', locked_at = NULL, run_at = ?1, updated_at = ?2, last_error = ?3
299
+ WHERE id = ?4 AND (locked_at = ?5 OR locked_at IS NULL)
219
300
  `);
220
- retryStmt.run(attempts, nextRunAt, now, errorMsg, job.id);
301
+ retryStmt.run(nextRunAt, now, errorMsg, job.id, job.locked_at ?? null);
221
302
  } else {
222
303
  const failStmt = this.db.prepare(`
223
304
  UPDATE _synapse_jobs
224
- SET status = 'failed', attempts = ?1, updated_at = ?2, last_error = ?3
225
- WHERE id = ?4
305
+ SET status = 'failed', locked_at = NULL, updated_at = ?1, last_error = ?2
306
+ WHERE id = ?3 AND (locked_at = ?4 OR locked_at IS NULL)
226
307
  `);
227
- failStmt.run(attempts, now, errorMsg, job.id);
308
+ failStmt.run(now, errorMsg, job.id, job.locked_at ?? null);
228
309
  }
229
310
  return true;
230
311
  }
@@ -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 {
@@ -25,6 +27,8 @@ export interface RateLimitResult {
25
27
  limit: number;
26
28
  remaining: number;
27
29
  resetMs: number;
30
+ /** True when the refusal is due to a saturated registry rather than an exhausted bucket. */
31
+ saturated?: boolean;
28
32
  }
29
33
 
30
34
  interface Bucket {
@@ -37,6 +41,7 @@ export class TokenBucketRateLimiter {
37
41
  private capacity: number;
38
42
  private refillRate: number;
39
43
  private idleTimeoutMs: number;
44
+ private maxBuckets: number;
40
45
  private buckets: Map<string, Bucket> = new Map();
41
46
  private cleanupTimer: ReturnType<typeof setInterval> | null = null;
42
47
 
@@ -47,6 +52,7 @@ export class TokenBucketRateLimiter {
47
52
  this.refillRate = options.refillRate ?? (Number.isFinite(envRps) && envRps > 0 ? envRps : 100);
48
53
  this.capacity = options.capacity ?? (Number.isFinite(envBurst) && envBurst > 0 ? envBurst : 150);
49
54
  this.idleTimeoutMs = options.idleTimeoutMs ?? 60_000;
55
+ this.maxBuckets = options.maxBuckets ?? 10_000;
50
56
 
51
57
  // Run background prune of idle buckets every minute
52
58
  this.cleanupTimer = setInterval(() => this.pruneIdleBuckets(), this.idleTimeoutMs);
@@ -57,18 +63,36 @@ export class TokenBucketRateLimiter {
57
63
 
58
64
  /**
59
65
  * Consumes `cost` tokens from the bucket for `key`.
66
+ *
67
+ * When the registry is saturated with distinct identities the excess is refused
68
+ * instead of evicting a live bucket: eviction would hand a client that is over its
69
+ * allowance a fresh one, which is precisely the bypass a spoofed identity stream
70
+ * is looking for.
60
71
  */
61
72
  consume(key: string, cost = 1): RateLimitResult {
62
73
  const now = Date.now();
63
74
  let bucket = this.buckets.get(key);
64
75
 
65
76
  if (!bucket) {
77
+ if (this.buckets.size >= this.maxBuckets) {
78
+ return {
79
+ allowed: false,
80
+ limit: this.capacity,
81
+ remaining: 0,
82
+ resetMs: Math.max(1000, Math.ceil(this.idleTimeoutMs / 4)),
83
+ saturated: true
84
+ };
85
+ }
66
86
  bucket = {
67
87
  tokens: this.capacity,
68
88
  lastRefill: now,
69
89
  lastAccess: now
70
90
  };
71
91
  this.buckets.set(key, bucket);
92
+ } else {
93
+ // Move to end to track recency (LRU)
94
+ this.buckets.delete(key);
95
+ this.buckets.set(key, bucket);
72
96
  }
73
97
 
74
98
  // Refill tokens based on elapsed time