@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.
Files changed (38) hide show
  1. package/README.md +4 -3
  2. package/bin/synapse.ts +74 -2
  3. package/package.json +2 -2
  4. package/src/client/components.tsx +459 -1
  5. package/src/client/context.tsx +23 -3
  6. package/src/client/error-boundary.tsx +59 -0
  7. package/src/client/hooks.ts +100 -1
  8. package/src/client/toast.tsx +219 -0
  9. package/src/client/upload.ts +153 -0
  10. package/src/client.ts +37 -2
  11. package/src/compiler/client-bundler.ts +94 -2
  12. package/src/compiler/context-bench.ts +2 -0
  13. package/src/compiler/contract.ts +1 -1
  14. package/src/compiler/oracle-runner.ts +10 -1
  15. package/src/compiler/scaffolder.ts +113 -4
  16. package/src/compiler/slice-splitter.ts +21 -3
  17. package/src/compiler/slice-templates.ts +234 -10
  18. package/src/compiler/typescript-api.ts +33 -0
  19. package/src/core/action-context.ts +50 -1
  20. package/src/core/cron-parser.ts +167 -0
  21. package/src/core/database-client.ts +35 -0
  22. package/src/core/index.ts +2 -0
  23. package/src/core/jobs.ts +37 -0
  24. package/src/core/oauth-client.ts +322 -0
  25. package/src/core/postgres-client.ts +37 -0
  26. package/src/core/postgres-queue.ts +51 -2
  27. package/src/core/query-builder.ts +166 -34
  28. package/src/core/session-context.ts +45 -4
  29. package/src/core/session-token.ts +16 -1
  30. package/src/core/sqlite-client.ts +20 -0
  31. package/src/mcp/server.ts +257 -1
  32. package/src/mcp/tools.ts +92 -1
  33. package/src/project-status.ts +103 -0
  34. package/src/runtime/client-entry.ts +11 -5
  35. package/src/runtime/queue-engine.ts +120 -5
  36. package/src/runtime/security-headers.ts +35 -0
  37. package/src/runtime/server.ts +300 -58
  38. 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, ?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, '&amp;')
31
+ .replace(/</g, '&lt;')
32
+ .replace(/>/g, '&gt;')
33
+ .replace(/"/g, '&quot;')
34
+ .replace(/'/g, '&#39;');
35
+ }