@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.
- package/README.md +108 -10
- package/bin/synapse.ts +263 -218
- package/package.json +1 -1
- package/src/client/components.tsx +26 -9
- package/src/client/outlet.tsx +40 -0
- package/src/client/sub-slice.tsx +45 -0
- package/src/client/use-websocket.ts +146 -0
- package/src/client.ts +12 -0
- package/src/compiler/client-bundler.ts +17 -7
- package/src/compiler/context-bench.ts +7 -1
- package/src/compiler/contract.ts +31 -4
- package/src/compiler/db-schema-generator.ts +39 -8
- package/src/compiler/fields-parser.ts +28 -1
- package/src/compiler/impact-analyzer.ts +31 -1
- package/src/compiler/migration-runner.ts +144 -11
- package/src/compiler/optional-coverage.ts +137 -0
- package/src/compiler/oracle-runner.ts +8 -3
- package/src/compiler/scaffolder.ts +174 -3
- package/src/compiler/schema-dag.ts +6 -3
- package/src/compiler/slice-discovery.ts +8 -1
- package/src/compiler/slice-splitter.ts +67 -6
- package/src/compiler/slice-templates.ts +10 -0
- package/src/compiler/standalone-builder.ts +16 -2
- package/src/core/action-context.ts +101 -8
- package/src/core/cache.ts +139 -0
- package/src/core/config.ts +124 -10
- package/src/core/database-client.ts +68 -6
- package/src/core/index.ts +5 -0
- package/src/core/path-guard.ts +105 -0
- package/src/core/postgres-client.ts +6 -0
- package/src/core/postgres-queue.ts +45 -7
- package/src/core/query-builder.ts +75 -60
- package/src/core/session-context.ts +1 -1
- package/src/core/session-token.ts +115 -8
- package/src/core/sqlite-client.ts +26 -1
- package/src/core/storage.ts +61 -24
- package/src/core/topics.ts +84 -0
- package/src/core/webhook-signature.ts +151 -0
- package/src/core/websocket.ts +30 -0
- package/src/index.ts +10 -2
- package/src/mcp/server.ts +78 -173
- package/src/mcp/tools.ts +238 -0
- package/src/project-status.ts +330 -0
- package/src/runtime/discovery-rules.ts +20 -1
- package/src/runtime/event-hub.ts +6 -1
- package/src/runtime/network-guard.ts +182 -0
- package/src/runtime/queue-engine.ts +102 -21
- package/src/runtime/rate-limiter.ts +24 -0
- package/src/runtime/server.ts +1380 -251
- package/src/runtime/uploads.ts +199 -19
- package/src/version.ts +11 -0
- package/templates/starter/.jev.json +1 -1
- 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
|
-
*
|
|
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
|
-
|
|
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
|
|
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',
|
|
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(
|
|
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',
|
|
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(
|
|
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',
|
|
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(
|
|
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',
|
|
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(
|
|
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',
|
|
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(
|
|
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
|