@ismaelsoilet/synapsejs 1.9.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 +4 -3
- package/bin/synapse.ts +74 -2
- 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 +2 -0
- package/src/compiler/contract.ts +1 -1
- package/src/compiler/oracle-runner.ts +10 -1
- package/src/compiler/scaffolder.ts +113 -4
- package/src/compiler/slice-splitter.ts +21 -3
- package/src/compiler/slice-templates.ts +234 -10
- package/src/compiler/typescript-api.ts +33 -0
- package/src/core/action-context.ts +50 -1
- package/src/core/cron-parser.ts +167 -0
- package/src/core/database-client.ts +35 -0
- package/src/core/index.ts +2 -0
- package/src/core/jobs.ts +37 -0
- package/src/core/oauth-client.ts +322 -0
- package/src/core/postgres-client.ts +37 -0
- package/src/core/postgres-queue.ts +51 -2
- package/src/core/query-builder.ts +166 -34
- package/src/core/session-context.ts +45 -4
- package/src/core/session-token.ts +16 -1
- package/src/core/sqlite-client.ts +20 -0
- package/src/mcp/server.ts +257 -1
- package/src/mcp/tools.ts +92 -1
- package/src/project-status.ts +103 -0
- package/src/runtime/client-entry.ts +11 -5
- package/src/runtime/queue-engine.ts +120 -5
- package/src/runtime/security-headers.ts +35 -0
- package/src/runtime/server.ts +300 -58
- package/templates/starter/package.json +2 -2
|
@@ -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;
|
|
@@ -88,6 +92,7 @@ export class QueueEngine {
|
|
|
88
92
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
89
93
|
max_attempts INTEGER NOT NULL DEFAULT 3,
|
|
90
94
|
backoff_seconds INTEGER NOT NULL DEFAULT 5,
|
|
95
|
+
cron TEXT,
|
|
91
96
|
run_at INTEGER NOT NULL,
|
|
92
97
|
created_at INTEGER NOT NULL,
|
|
93
98
|
updated_at INTEGER NOT NULL,
|
|
@@ -102,6 +107,11 @@ export class QueueEngine {
|
|
|
102
107
|
} catch {
|
|
103
108
|
// Column already exists
|
|
104
109
|
}
|
|
110
|
+
try {
|
|
111
|
+
this.db.run('ALTER TABLE _synapse_jobs ADD COLUMN cron TEXT;');
|
|
112
|
+
} catch {
|
|
113
|
+
// Column already exists
|
|
114
|
+
}
|
|
105
115
|
try {
|
|
106
116
|
this.db.run('CREATE INDEX IF NOT EXISTS idx_synapse_jobs_stale ON _synapse_jobs (status, locked_at);');
|
|
107
117
|
} catch {
|
|
@@ -114,13 +124,57 @@ export class QueueEngine {
|
|
|
114
124
|
*/
|
|
115
125
|
registerJob<T>(job: JobDefinition<T>): void {
|
|
116
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
|
+
}
|
|
117
171
|
}
|
|
118
172
|
|
|
119
173
|
/**
|
|
120
174
|
* Enqueues a job for background processing.
|
|
121
175
|
*/
|
|
122
176
|
async enqueue<TPayload>(
|
|
123
|
-
jobOrName: { name: string; retryLimit?: number; backoffSeconds?: number } | string,
|
|
177
|
+
jobOrName: { name: string; retryLimit?: number; backoffSeconds?: number; cron?: string } | string,
|
|
124
178
|
payload: TPayload,
|
|
125
179
|
options?: JobEnqueueOptions
|
|
126
180
|
): Promise<string> {
|
|
@@ -128,19 +182,25 @@ export class QueueEngine {
|
|
|
128
182
|
const name = typeof jobOrName === 'string' ? jobOrName : jobOrName.name;
|
|
129
183
|
const maxAttempts = typeof jobOrName === 'object' && jobOrName.retryLimit ? jobOrName.retryLimit : 3;
|
|
130
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
|
+
|
|
131
191
|
const priority = options?.priority ?? 0;
|
|
132
192
|
const delaySeconds = options?.delaySeconds ?? 0;
|
|
133
193
|
const now = Date.now();
|
|
134
|
-
const runAt = now + delaySeconds * 1000;
|
|
194
|
+
const runAt = options?.runAt ?? now + delaySeconds * 1000;
|
|
135
195
|
const payloadStr = JSON.stringify(payload);
|
|
136
196
|
|
|
137
197
|
const stmt = this.db.prepare(`
|
|
138
198
|
INSERT INTO _synapse_jobs (
|
|
139
|
-
id, name, payload, status, priority, attempts, max_attempts, backoff_seconds, run_at, created_at, updated_at
|
|
140
|
-
) 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)
|
|
141
201
|
`);
|
|
142
202
|
|
|
143
|
-
stmt.run(id, name, payloadStr, priority, maxAttempts, backoffSeconds, runAt, now);
|
|
203
|
+
stmt.run(id, name, payloadStr, priority, maxAttempts, backoffSeconds, cron, runAt, now);
|
|
144
204
|
return id;
|
|
145
205
|
}
|
|
146
206
|
|
|
@@ -284,6 +344,10 @@ export class QueueEngine {
|
|
|
284
344
|
WHERE id = ?2 AND (locked_at = ?3 OR locked_at IS NULL)
|
|
285
345
|
`);
|
|
286
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
|
+
|
|
287
351
|
return true;
|
|
288
352
|
} catch (handlerErr: unknown) {
|
|
289
353
|
const errorMsg = handlerErr instanceof Error ? handlerErr.message : String(handlerErr);
|
|
@@ -306,11 +370,62 @@ export class QueueEngine {
|
|
|
306
370
|
WHERE id = ?3 AND (locked_at = ?4 OR locked_at IS NULL)
|
|
307
371
|
`);
|
|
308
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);
|
|
309
376
|
}
|
|
310
377
|
return true;
|
|
311
378
|
}
|
|
312
379
|
}
|
|
313
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
|
+
|
|
314
429
|
/**
|
|
315
430
|
* Returns a snapshot of job counts by status.
|
|
316
431
|
*/
|
|
@@ -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
|
+
}
|