@ismaelsoilet/synapsejs 1.8.0 → 1.11.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 +98 -6
- package/bin/synapse.ts +291 -241
- package/package.json +2 -2
- package/src/client/components.tsx +459 -1
- package/src/client/context.tsx +23 -3
- package/src/client/error-boundary.tsx +59 -0
- package/src/client/hooks.ts +100 -1
- package/src/client/toast.tsx +219 -0
- package/src/client/upload.ts +153 -0
- package/src/client.ts +37 -2
- package/src/compiler/client-bundler.ts +94 -2
- package/src/compiler/context-bench.ts +9 -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 +10 -2
- package/src/compiler/optional-coverage.ts +137 -0
- package/src/compiler/oracle-runner.ts +18 -4
- package/src/compiler/scaffolder.ts +227 -9
- package/src/compiler/slice-discovery.ts +8 -1
- package/src/compiler/slice-splitter.ts +68 -6
- package/src/compiler/slice-templates.ts +234 -10
- package/src/compiler/typescript-api.ts +33 -0
- package/src/core/action-context.ts +101 -15
- package/src/core/config.ts +88 -10
- package/src/core/cron-parser.ts +167 -0
- package/src/core/database-client.ts +103 -6
- package/src/core/index.ts +5 -0
- package/src/core/jobs.ts +37 -0
- package/src/core/oauth-client.ts +322 -0
- package/src/core/path-guard.ts +105 -0
- package/src/core/postgres-client.ts +37 -0
- package/src/core/postgres-queue.ts +76 -9
- package/src/core/query-builder.ts +166 -34
- package/src/core/session-context.ts +45 -4
- package/src/core/session-token.ts +124 -8
- package/src/core/sqlite-client.ts +20 -0
- package/src/core/storage.ts +60 -19
- package/src/core/topics.ts +84 -0
- package/src/core/webhook-signature.ts +151 -0
- package/src/index.ts +2 -1
- package/src/mcp/server.ts +317 -218
- package/src/mcp/tools.ts +329 -0
- package/src/project-status.ts +433 -0
- package/src/runtime/client-entry.ts +11 -5
- package/src/runtime/network-guard.ts +3 -1
- package/src/runtime/queue-engine.ts +167 -5
- package/src/runtime/rate-limiter.ts +14 -4
- package/src/runtime/security-headers.ts +35 -0
- package/src/runtime/server.ts +1174 -236
- package/src/runtime/uploads.ts +199 -19
- package/src/version.ts +9 -2
- package/templates/starter/package.json +2 -2
- package/templates/starter/tsconfig.json +1 -0
|
@@ -15,9 +15,12 @@ import {
|
|
|
15
15
|
type DatabaseClient,
|
|
16
16
|
type JobDefinition,
|
|
17
17
|
type JobEnqueueOptions,
|
|
18
|
+
JobScheduleError,
|
|
19
|
+
jobPayloadMatchesSchema,
|
|
18
20
|
type SessionContext,
|
|
19
21
|
type StructuredLogger
|
|
20
22
|
} from '../core';
|
|
23
|
+
import { parseNextCronOccurrence } from '../core/cron-parser';
|
|
21
24
|
|
|
22
25
|
export interface JobRecord {
|
|
23
26
|
id: string;
|
|
@@ -28,6 +31,7 @@ export interface JobRecord {
|
|
|
28
31
|
attempts: number;
|
|
29
32
|
max_attempts: number;
|
|
30
33
|
backoff_seconds: number;
|
|
34
|
+
cron?: string | null;
|
|
31
35
|
run_at: number;
|
|
32
36
|
created_at: number;
|
|
33
37
|
updated_at: number;
|
|
@@ -50,6 +54,8 @@ export class QueueEngine {
|
|
|
50
54
|
private registry: Map<string, JobDefinition<unknown>> = new Map();
|
|
51
55
|
private logger: StructuredLogger;
|
|
52
56
|
private visibilityTimeoutMs: number;
|
|
57
|
+
/** Set at the start of shutdown: no new claim is taken from that moment on. */
|
|
58
|
+
private claimingStopped = false;
|
|
53
59
|
|
|
54
60
|
constructor(options: QueueEngineOptions = {}) {
|
|
55
61
|
this.logger = options.logger || createDefaultLogger('QueueEngine');
|
|
@@ -86,6 +92,7 @@ export class QueueEngine {
|
|
|
86
92
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
87
93
|
max_attempts INTEGER NOT NULL DEFAULT 3,
|
|
88
94
|
backoff_seconds INTEGER NOT NULL DEFAULT 5,
|
|
95
|
+
cron TEXT,
|
|
89
96
|
run_at INTEGER NOT NULL,
|
|
90
97
|
created_at INTEGER NOT NULL,
|
|
91
98
|
updated_at INTEGER NOT NULL,
|
|
@@ -100,6 +107,11 @@ export class QueueEngine {
|
|
|
100
107
|
} catch {
|
|
101
108
|
// Column already exists
|
|
102
109
|
}
|
|
110
|
+
try {
|
|
111
|
+
this.db.run('ALTER TABLE _synapse_jobs ADD COLUMN cron TEXT;');
|
|
112
|
+
} catch {
|
|
113
|
+
// Column already exists
|
|
114
|
+
}
|
|
103
115
|
try {
|
|
104
116
|
this.db.run('CREATE INDEX IF NOT EXISTS idx_synapse_jobs_stale ON _synapse_jobs (status, locked_at);');
|
|
105
117
|
} catch {
|
|
@@ -112,13 +124,57 @@ export class QueueEngine {
|
|
|
112
124
|
*/
|
|
113
125
|
registerJob<T>(job: JobDefinition<T>): void {
|
|
114
126
|
this.registry.set(job.name, job as JobDefinition<unknown>);
|
|
127
|
+
|
|
128
|
+
if (job.cron && job.runAt !== undefined) {
|
|
129
|
+
throw new JobScheduleError(
|
|
130
|
+
'CONFLICTING_SCHEDULE',
|
|
131
|
+
`Job "${job.name}" declares both cron and runAt; choose one schedule.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const initialPayload = job.cronPayload ? job.cronPayload() : ({} as T);
|
|
136
|
+
|
|
137
|
+
if (!jobPayloadMatchesSchema(job.schema, initialPayload)) {
|
|
138
|
+
throw new JobScheduleError(
|
|
139
|
+
'INVALID_CRON_PAYLOAD',
|
|
140
|
+
`Job "${job.name}" has a schema that rejects its initial cron payload.`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (job.cron) {
|
|
145
|
+
const existing = this.db
|
|
146
|
+
.prepare(`SELECT id FROM _synapse_jobs WHERE name = ?1 AND status IN ('pending', 'running') LIMIT 1`)
|
|
147
|
+
.get(job.name);
|
|
148
|
+
|
|
149
|
+
if (!existing) {
|
|
150
|
+
try {
|
|
151
|
+
const nextDate = parseNextCronOccurrence(job.cron, new Date());
|
|
152
|
+
const delaySeconds = Math.max(0, Math.floor((nextDate.getTime() - Date.now()) / 1000));
|
|
153
|
+
void this.enqueue(job, initialPayload, { delaySeconds }).catch((err) => {
|
|
154
|
+
this.logger.error(`Falha ao registrar agendamento cron inicial para ${job.name}: ${err}`);
|
|
155
|
+
});
|
|
156
|
+
} catch (err) {
|
|
157
|
+
this.logger.error(`Falha ao registrar agendamento cron inicial para ${job.name}: ${err}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} else if (job.runAt !== undefined) {
|
|
161
|
+
const existing = this.db
|
|
162
|
+
.prepare(`SELECT id FROM _synapse_jobs WHERE name = ?1 AND status IN ('pending', 'running') LIMIT 1`)
|
|
163
|
+
.get(job.name);
|
|
164
|
+
|
|
165
|
+
if (!existing) {
|
|
166
|
+
void this.enqueue(job, initialPayload, { runAt: job.runAt }).catch((err) => {
|
|
167
|
+
this.logger.error(`Falha ao registrar agendamento runAt para ${job.name}: ${err}`);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
115
171
|
}
|
|
116
172
|
|
|
117
173
|
/**
|
|
118
174
|
* Enqueues a job for background processing.
|
|
119
175
|
*/
|
|
120
176
|
async enqueue<TPayload>(
|
|
121
|
-
jobOrName: { name: string; retryLimit?: number; backoffSeconds?: number } | string,
|
|
177
|
+
jobOrName: { name: string; retryLimit?: number; backoffSeconds?: number; cron?: string } | string,
|
|
122
178
|
payload: TPayload,
|
|
123
179
|
options?: JobEnqueueOptions
|
|
124
180
|
): Promise<string> {
|
|
@@ -126,27 +182,78 @@ export class QueueEngine {
|
|
|
126
182
|
const name = typeof jobOrName === 'string' ? jobOrName : jobOrName.name;
|
|
127
183
|
const maxAttempts = typeof jobOrName === 'object' && jobOrName.retryLimit ? jobOrName.retryLimit : 3;
|
|
128
184
|
const backoffSeconds = typeof jobOrName === 'object' && jobOrName.backoffSeconds ? jobOrName.backoffSeconds : 5;
|
|
185
|
+
const cron = typeof jobOrName === 'object' && jobOrName.cron ? jobOrName.cron : null;
|
|
186
|
+
|
|
187
|
+
if (options?.runAt !== undefined && options?.delaySeconds !== undefined) {
|
|
188
|
+
throw new JobScheduleError('CONFLICTING_SCHEDULE', `Job "${name}" cannot declare both runAt and delaySeconds.`);
|
|
189
|
+
}
|
|
190
|
+
|
|
129
191
|
const priority = options?.priority ?? 0;
|
|
130
192
|
const delaySeconds = options?.delaySeconds ?? 0;
|
|
131
193
|
const now = Date.now();
|
|
132
|
-
const runAt = now + delaySeconds * 1000;
|
|
194
|
+
const runAt = options?.runAt ?? now + delaySeconds * 1000;
|
|
133
195
|
const payloadStr = JSON.stringify(payload);
|
|
134
196
|
|
|
135
197
|
const stmt = this.db.prepare(`
|
|
136
198
|
INSERT INTO _synapse_jobs (
|
|
137
|
-
id, name, payload, status, priority, attempts, max_attempts, backoff_seconds, run_at, created_at, updated_at
|
|
138
|
-
) VALUES (?1, ?2, ?3, 'pending', ?4, 0, ?5, ?6, ?7, ?8, ?
|
|
199
|
+
id, name, payload, status, priority, attempts, max_attempts, backoff_seconds, cron, run_at, created_at, updated_at
|
|
200
|
+
) VALUES (?1, ?2, ?3, 'pending', ?4, 0, ?5, ?6, ?7, ?8, ?9, ?9)
|
|
139
201
|
`);
|
|
140
202
|
|
|
141
|
-
stmt.run(id, name, payloadStr, priority, maxAttempts, backoffSeconds, runAt, now);
|
|
203
|
+
stmt.run(id, name, payloadStr, priority, maxAttempts, backoffSeconds, cron, runAt, now);
|
|
142
204
|
return id;
|
|
143
205
|
}
|
|
144
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Stops the engine from claiming any new work. Jobs enqueued by a request that
|
|
209
|
+
* was in flight remain pending for the next process, instead of being claimed
|
|
210
|
+
* during a shutdown that is about to close the database.
|
|
211
|
+
*/
|
|
212
|
+
stopClaiming(): void {
|
|
213
|
+
this.claimingStopped = true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
get isClaimingStopped(): boolean {
|
|
217
|
+
return this.claimingStopped;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Returns every job that is still marked running to the queue. The attempt
|
|
222
|
+
* counter is left exactly as the interrupted claim left it — releasing must not
|
|
223
|
+
* consume a second attempt for one claim — and a job that already reached its
|
|
224
|
+
* maximum is marked failed instead of being handed back for another run.
|
|
225
|
+
*/
|
|
226
|
+
interruptRunningJobs(): { requeued: number; failed: number } {
|
|
227
|
+
const now = Date.now();
|
|
228
|
+
|
|
229
|
+
const failed = this.db
|
|
230
|
+
.prepare(`
|
|
231
|
+
UPDATE _synapse_jobs
|
|
232
|
+
SET status = 'failed', locked_at = NULL, updated_at = ?1, last_error = 'Interrompido pelo encerramento do processo'
|
|
233
|
+
WHERE status = 'running' AND attempts >= max_attempts
|
|
234
|
+
`)
|
|
235
|
+
.run(now);
|
|
236
|
+
|
|
237
|
+
const requeued = this.db
|
|
238
|
+
.prepare(`
|
|
239
|
+
UPDATE _synapse_jobs
|
|
240
|
+
SET status = 'pending', locked_at = NULL, updated_at = ?1, last_error = 'Interrompido pelo encerramento do processo'
|
|
241
|
+
WHERE status = 'running' AND attempts < max_attempts
|
|
242
|
+
`)
|
|
243
|
+
.run(now);
|
|
244
|
+
|
|
245
|
+
return { requeued: Number(requeued.changes ?? 0), failed: Number(failed.changes ?? 0) };
|
|
246
|
+
}
|
|
247
|
+
|
|
145
248
|
/**
|
|
146
249
|
* Atomically claims the next pending job eligible for execution, or recovers
|
|
147
250
|
* any stale 'running' job whose worker has died (visibility timeout exceeded).
|
|
148
251
|
*/
|
|
149
252
|
claimNextJob(visibilityTimeoutMs?: number): JobRecord | null {
|
|
253
|
+
if (this.claimingStopped) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
150
257
|
const now = Date.now();
|
|
151
258
|
const timeout = visibilityTimeoutMs ?? this.visibilityTimeoutMs;
|
|
152
259
|
const staleThreshold = now - timeout;
|
|
@@ -237,6 +344,10 @@ export class QueueEngine {
|
|
|
237
344
|
WHERE id = ?2 AND (locked_at = ?3 OR locked_at IS NULL)
|
|
238
345
|
`);
|
|
239
346
|
completeStmt.run(now, job.id, job.locked_at ?? null);
|
|
347
|
+
|
|
348
|
+
// Reschedule recurring cron job if defined
|
|
349
|
+
await this.rescheduleCronIfConfigured(jobDef, parsedPayload, now);
|
|
350
|
+
|
|
240
351
|
return true;
|
|
241
352
|
} catch (handlerErr: unknown) {
|
|
242
353
|
const errorMsg = handlerErr instanceof Error ? handlerErr.message : String(handlerErr);
|
|
@@ -259,11 +370,62 @@ export class QueueEngine {
|
|
|
259
370
|
WHERE id = ?3 AND (locked_at = ?4 OR locked_at IS NULL)
|
|
260
371
|
`);
|
|
261
372
|
failStmt.run(now, errorMsg, job.id, job.locked_at ?? null);
|
|
373
|
+
|
|
374
|
+
// Recurring cron jobs must not be permanently halted when an individual run exhausts retries
|
|
375
|
+
await this.rescheduleCronIfConfigured(jobDef, parsedPayload, now);
|
|
262
376
|
}
|
|
263
377
|
return true;
|
|
264
378
|
}
|
|
265
379
|
}
|
|
266
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Reschedules the next occurrence of a recurring cron job.
|
|
383
|
+
*/
|
|
384
|
+
private async rescheduleCronIfConfigured(
|
|
385
|
+
jobDef: JobDefinition,
|
|
386
|
+
fallbackPayload: unknown,
|
|
387
|
+
now: number
|
|
388
|
+
): Promise<void> {
|
|
389
|
+
if (!jobDef.cron) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
const nextDate = parseNextCronOccurrence(jobDef.cron, new Date(now));
|
|
394
|
+
const delaySeconds = Math.max(1, Math.floor((nextDate.getTime() - now) / 1000));
|
|
395
|
+
const nextPayload = jobDef.cronPayload ? jobDef.cronPayload() : fallbackPayload;
|
|
396
|
+
|
|
397
|
+
if (!jobPayloadMatchesSchema(jobDef.schema, nextPayload)) {
|
|
398
|
+
this.logger.error(`Payload da recorrência de ${jobDef.name} não satisfaz o schema; recorrência não agendada.`);
|
|
399
|
+
} else {
|
|
400
|
+
await this.enqueue(jobDef, nextPayload, { delaySeconds });
|
|
401
|
+
}
|
|
402
|
+
} catch (cronErr) {
|
|
403
|
+
this.logger.error(`Falha ao reagendar job cron ${jobDef.name}: ${cronErr}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Retrieves failed jobs from Dead Letter Queue for inspection.
|
|
409
|
+
*/
|
|
410
|
+
getDeadLetterJobs(limit = 50): JobRecord[] {
|
|
411
|
+
return this.db
|
|
412
|
+
.prepare(`SELECT * FROM _synapse_jobs WHERE status = 'failed' ORDER BY updated_at DESC LIMIT ?1`)
|
|
413
|
+
.all(limit) as JobRecord[];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Resets a failed dead-letter job to pending for re-execution.
|
|
418
|
+
*/
|
|
419
|
+
retryJob(id: string): boolean {
|
|
420
|
+
const stmt = this.db.prepare(`
|
|
421
|
+
UPDATE _synapse_jobs
|
|
422
|
+
SET status = 'pending', attempts = 0, locked_at = NULL, run_at = ?1, updated_at = ?1
|
|
423
|
+
WHERE id = ?2 AND status = 'failed'
|
|
424
|
+
`);
|
|
425
|
+
const res = stmt.run(Date.now(), id);
|
|
426
|
+
return Number(res.changes ?? 0) > 0;
|
|
427
|
+
}
|
|
428
|
+
|
|
267
429
|
/**
|
|
268
430
|
* Returns a snapshot of job counts by status.
|
|
269
431
|
*/
|
|
@@ -27,6 +27,8 @@ export interface RateLimitResult {
|
|
|
27
27
|
limit: number;
|
|
28
28
|
remaining: number;
|
|
29
29
|
resetMs: number;
|
|
30
|
+
/** True when the refusal is due to a saturated registry rather than an exhausted bucket. */
|
|
31
|
+
saturated?: boolean;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
interface Bucket {
|
|
@@ -61,6 +63,11 @@ export class TokenBucketRateLimiter {
|
|
|
61
63
|
|
|
62
64
|
/**
|
|
63
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.
|
|
64
71
|
*/
|
|
65
72
|
consume(key: string, cost = 1): RateLimitResult {
|
|
66
73
|
const now = Date.now();
|
|
@@ -68,10 +75,13 @@ export class TokenBucketRateLimiter {
|
|
|
68
75
|
|
|
69
76
|
if (!bucket) {
|
|
70
77
|
if (this.buckets.size >= this.maxBuckets) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
this.
|
|
74
|
-
|
|
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
|
+
};
|
|
75
85
|
}
|
|
76
86
|
bucket = {
|
|
77
87
|
tokens: this.capacity,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - HTTP Security Headers & Status Mapping Utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const ERROR_STATUS: Array<{ pattern: RegExp; status: number }> = [
|
|
6
|
+
{ pattern: /^UNAUTHORIZED$/, status: 401 },
|
|
7
|
+
{ pattern: /^FORBIDDEN$/, status: 403 },
|
|
8
|
+
{ pattern: /_NOT_FOUND$/, status: 404 },
|
|
9
|
+
{ pattern: /^(DUPLICATE_|ALREADY_|CONFLICT)/, status: 409 },
|
|
10
|
+
{ pattern: /^(INVALID_|MALFORMED)/, status: 422 },
|
|
11
|
+
{ pattern: /^(NO_DATABASE|PERSISTENCE_FAILED)/, status: 500 },
|
|
12
|
+
{ pattern: /_FAILED$/, status: 500 }
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export function httpStatusForError(error: unknown): number {
|
|
16
|
+
const code = typeof error === 'string' ? error : '';
|
|
17
|
+
|
|
18
|
+
for (const entry of ERROR_STATUS) {
|
|
19
|
+
if (entry.pattern.test(code)) {
|
|
20
|
+
return entry.status;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return 400;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Escapes special HTML characters to prevent Reflected/Stored XSS. */
|
|
28
|
+
export function escapeHtml(str: string): string {
|
|
29
|
+
return str
|
|
30
|
+
.replace(/&/g, '&')
|
|
31
|
+
.replace(/</g, '<')
|
|
32
|
+
.replace(/>/g, '>')
|
|
33
|
+
.replace(/"/g, '"')
|
|
34
|
+
.replace(/'/g, ''');
|
|
35
|
+
}
|