@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.
Files changed (55) hide show
  1. package/README.md +98 -6
  2. package/bin/synapse.ts +291 -241
  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 +9 -1
  13. package/src/compiler/contract.ts +31 -4
  14. package/src/compiler/db-schema-generator.ts +39 -8
  15. package/src/compiler/fields-parser.ts +28 -1
  16. package/src/compiler/impact-analyzer.ts +31 -1
  17. package/src/compiler/migration-runner.ts +10 -2
  18. package/src/compiler/optional-coverage.ts +137 -0
  19. package/src/compiler/oracle-runner.ts +18 -4
  20. package/src/compiler/scaffolder.ts +227 -9
  21. package/src/compiler/slice-discovery.ts +8 -1
  22. package/src/compiler/slice-splitter.ts +68 -6
  23. package/src/compiler/slice-templates.ts +234 -10
  24. package/src/compiler/typescript-api.ts +33 -0
  25. package/src/core/action-context.ts +101 -15
  26. package/src/core/config.ts +88 -10
  27. package/src/core/cron-parser.ts +167 -0
  28. package/src/core/database-client.ts +103 -6
  29. package/src/core/index.ts +5 -0
  30. package/src/core/jobs.ts +37 -0
  31. package/src/core/oauth-client.ts +322 -0
  32. package/src/core/path-guard.ts +105 -0
  33. package/src/core/postgres-client.ts +37 -0
  34. package/src/core/postgres-queue.ts +76 -9
  35. package/src/core/query-builder.ts +166 -34
  36. package/src/core/session-context.ts +45 -4
  37. package/src/core/session-token.ts +124 -8
  38. package/src/core/sqlite-client.ts +20 -0
  39. package/src/core/storage.ts +60 -19
  40. package/src/core/topics.ts +84 -0
  41. package/src/core/webhook-signature.ts +151 -0
  42. package/src/index.ts +2 -1
  43. package/src/mcp/server.ts +317 -218
  44. package/src/mcp/tools.ts +329 -0
  45. package/src/project-status.ts +433 -0
  46. package/src/runtime/client-entry.ts +11 -5
  47. package/src/runtime/network-guard.ts +3 -1
  48. package/src/runtime/queue-engine.ts +167 -5
  49. package/src/runtime/rate-limiter.ts +14 -4
  50. package/src/runtime/security-headers.ts +35 -0
  51. package/src/runtime/server.ts +1174 -236
  52. package/src/runtime/uploads.ts +199 -19
  53. package/src/version.ts +9 -2
  54. package/templates/starter/package.json +2 -2
  55. 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, ?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
- const oldest = this.buckets.keys().next().value;
72
- if (oldest !== undefined) {
73
- this.buckets.delete(oldest);
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, '&amp;')
31
+ .replace(/</g, '&lt;')
32
+ .replace(/>/g, '&gt;')
33
+ .replace(/"/g, '&quot;')
34
+ .replace(/'/g, '&#39;');
35
+ }