@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
@@ -0,0 +1,322 @@
1
+ /**
2
+ * SynapseJS - Isomorphic OAuth2 & PKCE (Proof Key for Code Exchange) Client
3
+ *
4
+ * Provides RFC 7636 compliant PKCE generators and standard authorization / token exchange
5
+ * flows for Google, GitHub, Microsoft, and generic OAuth2/OIDC providers.
6
+ */
7
+
8
+ import { Err, Ok, type Result } from './machine-types';
9
+
10
+ export interface PkceChallenge {
11
+ codeVerifier: string;
12
+ codeChallenge: string;
13
+ codeChallengeMethod: 'S256';
14
+ }
15
+
16
+ /**
17
+ * Generates a high-entropy cryptographic code verifier and SHA-256 code challenge (RFC 7636).
18
+ */
19
+ export function generatePkceChallenge(): PkceChallenge {
20
+ const randomBytes = new Uint8Array(32);
21
+ crypto.getRandomValues(randomBytes);
22
+
23
+ // Base64URL encoding without padding
24
+ const codeVerifier = Buffer.from(randomBytes)
25
+ .toString('base64')
26
+ .replace(/\+/g, '-')
27
+ .replace(/\//g, '_')
28
+ .replace(/=+$/, '');
29
+
30
+ const hasher = new Bun.CryptoHasher('sha256');
31
+ hasher.update(codeVerifier);
32
+ const codeChallenge = hasher.digest('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
33
+
34
+ return {
35
+ codeVerifier,
36
+ codeChallenge,
37
+ codeChallengeMethod: 'S256'
38
+ };
39
+ }
40
+
41
+ export interface OAuthProviderConfig {
42
+ clientId: string;
43
+ clientSecret?: string;
44
+ authorizationUrl: string;
45
+ tokenUrl: string;
46
+ redirectUri: string;
47
+ scope: string[];
48
+ }
49
+
50
+ export interface BuildAuthUrlOptions {
51
+ config: OAuthProviderConfig;
52
+ state: string;
53
+ codeChallenge?: string;
54
+ extraParams?: Record<string, string>;
55
+ }
56
+
57
+ /**
58
+ * Constructs standard OAuth2 authorization URL with PKCE parameters.
59
+ */
60
+ export function buildOAuthAuthorizationUrl(options: BuildAuthUrlOptions): string {
61
+ const url = new URL(options.config.authorizationUrl);
62
+ url.searchParams.set('client_id', options.config.clientId);
63
+ url.searchParams.set('redirect_uri', options.config.redirectUri);
64
+ url.searchParams.set('response_type', 'code');
65
+ url.searchParams.set('scope', options.config.scope.join(' '));
66
+ url.searchParams.set('state', options.state);
67
+
68
+ if (options.codeChallenge) {
69
+ url.searchParams.set('code_challenge', options.codeChallenge);
70
+ url.searchParams.set('code_challenge_method', 'S256');
71
+ }
72
+
73
+ if (options.extraParams) {
74
+ for (const [key, value] of Object.entries(options.extraParams)) {
75
+ url.searchParams.set(key, value);
76
+ }
77
+ }
78
+
79
+ return url.toString();
80
+ }
81
+
82
+ export interface TokenExchangeOptions {
83
+ config: OAuthProviderConfig;
84
+ code: string;
85
+ codeVerifier?: string;
86
+ /** Maximum time to wait for the provider, in milliseconds (default: 10s). */
87
+ timeoutMs?: number;
88
+ }
89
+
90
+ /** Generates an opaque, high-entropy OAuth `state` value. */
91
+ export function generateOAuthState(): string {
92
+ const randomBytes = new Uint8Array(32);
93
+ crypto.getRandomValues(randomBytes);
94
+
95
+ return Buffer.from(randomBytes).toString('base64url');
96
+ }
97
+
98
+ /** Constant-time comparison of the state emitted by the server and the value returned. */
99
+ export function verifyOAuthState(expected: string, received: string | null | undefined): boolean {
100
+ if (!expected || !received) {
101
+ return false;
102
+ }
103
+
104
+ const expectedBuf = Buffer.from(expected);
105
+ const receivedBuf = Buffer.from(received);
106
+
107
+ if (expectedBuf.length !== receivedBuf.length) {
108
+ return false;
109
+ }
110
+
111
+ return require('node:crypto').timingSafeEqual(expectedBuf, receivedBuf);
112
+ }
113
+
114
+ export interface OAuthTokenResponse {
115
+ accessToken: string;
116
+ tokenType: string;
117
+ expiresIn?: number;
118
+ refreshToken?: string;
119
+ scope?: string;
120
+ idToken?: string;
121
+ raw: Record<string, unknown>;
122
+ }
123
+
124
+ /**
125
+ * Exchanges authorization code for access tokens via standard HTTP POST.
126
+ */
127
+ export async function exchangeOAuthCode(
128
+ options: TokenExchangeOptions
129
+ ): Promise<Result<OAuthTokenResponse, 'TOKEN_EXCHANGE_FAILED' | 'NETWORK_ERROR'>> {
130
+ const bodyParams = new URLSearchParams();
131
+ bodyParams.set('client_id', options.config.clientId);
132
+ bodyParams.set('code', options.code);
133
+ bodyParams.set('redirect_uri', options.config.redirectUri);
134
+ bodyParams.set('grant_type', 'authorization_code');
135
+
136
+ if (options.config.clientSecret) {
137
+ bodyParams.set('client_secret', options.config.clientSecret);
138
+ }
139
+
140
+ if (options.codeVerifier) {
141
+ bodyParams.set('code_verifier', options.codeVerifier);
142
+ }
143
+
144
+ const controller = new AbortController();
145
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 10_000);
146
+
147
+ try {
148
+ const response = await fetch(options.config.tokenUrl, {
149
+ method: 'POST',
150
+ headers: {
151
+ 'Content-Type': 'application/x-www-form-urlencoded',
152
+ Accept: 'application/json'
153
+ },
154
+ body: bodyParams.toString(),
155
+ signal: controller.signal
156
+ });
157
+
158
+ if (!response.ok) {
159
+ return Err('TOKEN_EXCHANGE_FAILED');
160
+ }
161
+
162
+ const data = (await response.json()) as Record<string, unknown>;
163
+ const accessToken = String(data.access_token || data.accessToken || '');
164
+
165
+ if (!accessToken) {
166
+ return Err('TOKEN_EXCHANGE_FAILED');
167
+ }
168
+
169
+ return Ok({
170
+ accessToken,
171
+ tokenType: String(data.token_type || data.tokenType || 'Bearer'),
172
+ expiresIn: typeof data.expires_in === 'number' ? data.expires_in : undefined,
173
+ refreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
174
+ scope: typeof data.scope === 'string' ? data.scope : undefined,
175
+ idToken: typeof data.id_token === 'string' ? data.id_token : undefined,
176
+ raw: data
177
+ });
178
+ } catch {
179
+ return Err('NETWORK_ERROR');
180
+ } finally {
181
+ clearTimeout(timeout);
182
+ }
183
+ }
184
+
185
+ // ============================================================================
186
+ // OAUTH PROVIDER PRESETS & UNIFIED USER PROFILE
187
+ // ============================================================================
188
+ export interface OAuthUserProfile {
189
+ id: string;
190
+ email?: string;
191
+ name?: string;
192
+ avatarUrl?: string;
193
+ provider: 'google' | 'github' | 'discord' | 'oidc';
194
+ raw: Record<string, unknown>;
195
+ }
196
+
197
+ export function googleOAuthPreset(options: {
198
+ clientId: string;
199
+ clientSecret: string;
200
+ redirectUri: string;
201
+ }): OAuthProviderConfig {
202
+ return {
203
+ clientId: options.clientId,
204
+ clientSecret: options.clientSecret,
205
+ authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
206
+ tokenUrl: 'https://oauth2.googleapis.com/token',
207
+ redirectUri: options.redirectUri,
208
+ scope: ['openid', 'email', 'profile']
209
+ };
210
+ }
211
+
212
+ export function githubOAuthPreset(options: {
213
+ clientId: string;
214
+ clientSecret: string;
215
+ redirectUri: string;
216
+ }): OAuthProviderConfig {
217
+ return {
218
+ clientId: options.clientId,
219
+ clientSecret: options.clientSecret,
220
+ authorizationUrl: 'https://github.com/login/oauth/authorize',
221
+ tokenUrl: 'https://github.com/login/oauth/access_token',
222
+ redirectUri: options.redirectUri,
223
+ scope: ['read:user', 'user:email']
224
+ };
225
+ }
226
+
227
+ export function discordOAuthPreset(options: {
228
+ clientId: string;
229
+ clientSecret: string;
230
+ redirectUri: string;
231
+ }): OAuthProviderConfig {
232
+ return {
233
+ clientId: options.clientId,
234
+ clientSecret: options.clientSecret,
235
+ authorizationUrl: 'https://discord.com/api/oauth2/authorize',
236
+ tokenUrl: 'https://discord.com/api/oauth2/token',
237
+ redirectUri: options.redirectUri,
238
+ scope: ['identify', 'email']
239
+ };
240
+ }
241
+
242
+ export async function fetchOAuthUserProfile(
243
+ provider: 'google' | 'github' | 'discord' | 'oidc',
244
+ accessToken: string,
245
+ userInfoUrl?: string
246
+ ): Promise<Result<OAuthUserProfile, 'FETCH_PROFILE_FAILED'>> {
247
+ const url =
248
+ userInfoUrl ||
249
+ (provider === 'google'
250
+ ? 'https://openidconnect.googleapis.com/v1/userinfo'
251
+ : provider === 'github'
252
+ ? 'https://api.github.com/user'
253
+ : provider === 'discord'
254
+ ? 'https://discord.com/api/users/@me'
255
+ : null);
256
+
257
+ if (!url) {
258
+ return Err('FETCH_PROFILE_FAILED');
259
+ }
260
+
261
+ try {
262
+ const res = await fetch(url, {
263
+ headers: {
264
+ Authorization: `Bearer ${accessToken}`,
265
+ Accept: 'application/json',
266
+ 'User-Agent': 'SynapseJS'
267
+ }
268
+ });
269
+
270
+ if (!res.ok) {
271
+ return Err('FETCH_PROFILE_FAILED');
272
+ }
273
+
274
+ const data = (await res.json()) as Record<string, unknown>;
275
+ let id = '';
276
+ let email: string | undefined;
277
+ let name: string | undefined;
278
+ let avatarUrl: string | undefined;
279
+
280
+ if (provider === 'google') {
281
+ id = String(data.sub || data.id || '');
282
+ email = typeof data.email === 'string' ? data.email : undefined;
283
+ name = typeof data.name === 'string' ? data.name : undefined;
284
+ avatarUrl = typeof data.picture === 'string' ? data.picture : undefined;
285
+ } else if (provider === 'github') {
286
+ id = String(data.id || '');
287
+ email = typeof data.email === 'string' ? data.email : undefined;
288
+ name = typeof data.name === 'string' ? data.name : typeof data.login === 'string' ? data.login : undefined;
289
+ avatarUrl = typeof data.avatar_url === 'string' ? data.avatar_url : undefined;
290
+ } else if (provider === 'discord') {
291
+ id = String(data.id || '');
292
+ email = typeof data.email === 'string' ? data.email : undefined;
293
+ name =
294
+ typeof data.global_name === 'string'
295
+ ? data.global_name
296
+ : typeof data.username === 'string'
297
+ ? data.username
298
+ : undefined;
299
+ avatarUrl = data.avatar ? `https://cdn.discordapp.com/avatars/${id}/${data.avatar}.png` : undefined;
300
+ } else {
301
+ id = String(data.sub || data.id || '');
302
+ email = typeof data.email === 'string' ? data.email : undefined;
303
+ name = typeof data.name === 'string' ? data.name : undefined;
304
+ avatarUrl = typeof data.picture === 'string' ? data.picture : undefined;
305
+ }
306
+
307
+ if (!id) {
308
+ return Err('FETCH_PROFILE_FAILED');
309
+ }
310
+
311
+ return Ok({
312
+ id,
313
+ email,
314
+ name,
315
+ avatarUrl,
316
+ provider,
317
+ raw: data
318
+ });
319
+ } catch {
320
+ return Err('FETCH_PROFILE_FAILED');
321
+ }
322
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * SynapseJS - Path Segment Guard
3
+ *
4
+ * The single rule that decides whether caller-supplied input may become one
5
+ * segment of a filesystem path. Every write surface — slice scaffolding,
6
+ * shared-module scaffolding, application creation and the upload parser —
7
+ * passes its input through this validator before joining, so containment
8
+ * cannot hold on one path and be missing on a sibling.
9
+ *
10
+ * It rejects rather than rewrites: a silently sanitized name turns a traversal
11
+ * into "expected behaviour".
12
+ */
13
+
14
+ import * as path from 'path';
15
+ import { Err, Ok, type Result } from './machine-types';
16
+
17
+ export const MAX_PATH_SEGMENT_LENGTH = 120;
18
+
19
+ /** An ordinary name: alphanumeric start, then alphanumerics, dot, underscore or dash. */
20
+ const SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
21
+
22
+ /** The same rule as a pattern source, for schemas that declare the constraint. */
23
+ export const PATH_SEGMENT_PATTERN = SEGMENT_PATTERN.source;
24
+
25
+ export type PathSegmentErrorCode = 'INVALID_PATH_SEGMENT';
26
+
27
+ /**
28
+ * Accepts a caller-supplied string only when it is one safe path segment.
29
+ *
30
+ * Rejections: separators, parent traversal, absolute prefixes, empty or
31
+ * blank segments, over-length input, and anything outside the plain-name
32
+ * pattern. Every write surface that joins caller input into a filesystem
33
+ * path applies exactly this rule.
34
+ */
35
+ export function validatePathSegment(
36
+ raw: string | null | undefined,
37
+ maxLength: number = MAX_PATH_SEGMENT_LENGTH
38
+ ): Result<string, PathSegmentErrorCode> {
39
+ if (typeof raw !== 'string') {
40
+ return Err('INVALID_PATH_SEGMENT');
41
+ }
42
+
43
+ const segment = raw.trim();
44
+
45
+ if (!segment || segment.length > maxLength) {
46
+ return Err('INVALID_PATH_SEGMENT');
47
+ }
48
+
49
+ if (segment.includes('/') || segment.includes('\\') || segment.includes('\0')) {
50
+ return Err('INVALID_PATH_SEGMENT');
51
+ }
52
+
53
+ if (segment.includes('..')) {
54
+ return Err('INVALID_PATH_SEGMENT');
55
+ }
56
+
57
+ if (path.isAbsolute(segment) || /^[A-Za-z]:/.test(segment)) {
58
+ return Err('INVALID_PATH_SEGMENT');
59
+ }
60
+
61
+ if (!SEGMENT_PATTERN.test(segment)) {
62
+ return Err('INVALID_PATH_SEGMENT');
63
+ }
64
+
65
+ return Ok(segment);
66
+ }
67
+
68
+ /**
69
+ * True only when `target` resolves to a location strictly inside `base`.
70
+ *
71
+ * This is the post-join assertion: character validation alone is defeated by
72
+ * a compound path that looks safe segment by segment, so the resolved target
73
+ * is compared against the resolved base before any write.
74
+ */
75
+ export function isInsideBase(base: string, target: string): boolean {
76
+ const resolvedBase = path.resolve(base);
77
+ const resolvedTarget = path.resolve(target);
78
+ const relative = path.relative(resolvedBase, resolvedTarget);
79
+
80
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
81
+ }
82
+
83
+ /**
84
+ * Validates every segment, joins them onto `base` and asserts the resolved
85
+ * target stays inside it. The only way to turn caller input into a write path.
86
+ */
87
+ export function resolveWithinBase(base: string, ...segments: string[]): Result<string, PathSegmentErrorCode> {
88
+ const resolvedBase = path.resolve(base);
89
+
90
+ for (const segment of segments) {
91
+ const validated = validatePathSegment(segment);
92
+
93
+ if (!validated.ok) {
94
+ return validated;
95
+ }
96
+ }
97
+
98
+ const target = path.join(resolvedBase, ...segments);
99
+
100
+ if (!isInsideBase(resolvedBase, target)) {
101
+ return Err('INVALID_PATH_SEGMENT');
102
+ }
103
+
104
+ return Ok(target);
105
+ }
@@ -95,6 +95,26 @@ export class PostgresDatabaseClient implements DatabaseClient {
95
95
  return rows.length;
96
96
  }
97
97
 
98
+ async count(table: string, where?: WhereCondition): Promise<number> {
99
+ const { sql, params } = compileSelect(table, {
100
+ aggregations: [{ fn: 'COUNT', column: '*', as: 'total' }],
101
+ where
102
+ });
103
+ const row = await this.queryOne<{ total: number | string }>(sql, params);
104
+ return row ? Number(row.total) : 0;
105
+ }
106
+
107
+ async exists(table: string, where: WhereCondition): Promise<boolean> {
108
+ const { sql, params } = compileSelect(table, { select: ['1'], where, limit: 1 });
109
+ const row = await this.queryOne<Record<string, unknown>>(sql, params);
110
+
111
+ return row !== null;
112
+ }
113
+
114
+ async aggregate<T = Record<string, unknown>>(table: string, options: QueryOptions): Promise<T[]> {
115
+ return this.findMany<T>(table, options);
116
+ }
117
+
98
118
  async transaction<T>(operation: (tx: DatabaseClient) => Promise<T>): Promise<T> {
99
119
  const res = await this.client.begin(async (sqlTx) => {
100
120
  const txClient: DatabaseClient = {
@@ -155,6 +175,23 @@ export class PostgresDatabaseClient implements DatabaseClient {
155
175
  const rows = await txClient.query(sql, params);
156
176
  return rows.length;
157
177
  },
178
+ count: async (table: string, where?: WhereCondition) => {
179
+ const { sql, params } = compileSelect(table, {
180
+ aggregations: [{ fn: 'COUNT', column: '*', as: 'total' }],
181
+ where
182
+ });
183
+ const row = await txClient.queryOne<{ total: number | string }>(sql, params);
184
+ return row ? Number(row.total) : 0;
185
+ },
186
+ exists: async (table: string, where: WhereCondition) => {
187
+ const { sql, params } = compileSelect(table, { select: ['1'], where, limit: 1 });
188
+ const row = await txClient.queryOne<Record<string, unknown>>(sql, params);
189
+
190
+ return row !== null;
191
+ },
192
+ aggregate: async <U = Record<string, unknown>>(table: string, options: QueryOptions) => {
193
+ return txClient.findMany<U>(table, options);
194
+ },
158
195
  transaction: async <U>(subOp: (nestedTx: DatabaseClient) => Promise<U>) => {
159
196
  return await subOp(txClient);
160
197
  }
@@ -14,6 +14,8 @@ import {
14
14
  createDefaultLogger,
15
15
  type JobDefinition,
16
16
  type JobEnqueueOptions,
17
+ JobScheduleError,
18
+ jobPayloadMatchesSchema,
17
19
  type SessionContext,
18
20
  type StructuredLogger
19
21
  } from './index';
@@ -97,6 +99,28 @@ export class PostgresQueueEngine {
97
99
 
98
100
  registerJob<T>(job: JobDefinition<T>): void {
99
101
  this.registry.set(job.name, job as JobDefinition<unknown>);
102
+
103
+ if (job.cron) {
104
+ throw new JobScheduleError(
105
+ 'CRON_UNSUPPORTED_ENGINE',
106
+ `Job "${job.name}" uses cron, which the PostgreSQL queue engine does not implement yet. Use the SQLite QueueEngine or schedule externally.`
107
+ );
108
+ }
109
+
110
+ if (job.runAt !== undefined) {
111
+ const payload = job.cronPayload ? job.cronPayload() : ({} as T);
112
+
113
+ if (!jobPayloadMatchesSchema(job.schema, payload)) {
114
+ throw new JobScheduleError(
115
+ 'INVALID_CRON_PAYLOAD',
116
+ `Job "${job.name}" has a schema that rejects its initial runAt payload.`
117
+ );
118
+ }
119
+
120
+ void this.enqueue(job, payload, { runAt: job.runAt }).catch((err) => {
121
+ this.logger.error(`Falha ao registrar agendamento runAt para ${job.name}: ${err}`);
122
+ });
123
+ }
100
124
  }
101
125
 
102
126
  async enqueue<TPayload>(
@@ -106,6 +130,13 @@ export class PostgresQueueEngine {
106
130
  ): Promise<string> {
107
131
  await this.initSchema();
108
132
 
133
+ if (options?.runAt !== undefined && options?.delaySeconds !== undefined) {
134
+ throw new JobScheduleError(
135
+ 'CONFLICTING_SCHEDULE',
136
+ `Job "${typeof jobOrName === 'string' ? jobOrName : jobOrName.name}" cannot declare both runAt and delaySeconds.`
137
+ );
138
+ }
139
+
109
140
  const id = `job_${crypto.randomUUID().replace(/-/g, '')}`;
110
141
  const name = typeof jobOrName === 'string' ? jobOrName : jobOrName.name;
111
142
  const maxAttempts = typeof jobOrName === 'object' && jobOrName.retryLimit ? jobOrName.retryLimit : 3;
@@ -113,7 +144,7 @@ export class PostgresQueueEngine {
113
144
  const priority = options?.priority ?? 0;
114
145
  const delaySeconds = options?.delaySeconds ?? 0;
115
146
  const now = Date.now();
116
- const runAt = now + delaySeconds * 1000;
147
+ const runAt = options?.runAt ?? now + delaySeconds * 1000;
117
148
  const payloadStr = JSON.stringify(payload);
118
149
 
119
150
  await this.db.query(
@@ -146,14 +177,32 @@ export class PostgresQueueEngine {
146
177
  );
147
178
 
148
179
  for (const deadJob of deadJobs) {
149
- await this.db
150
- .query(
151
- `INSERT INTO _synapse_jobs_dlq (id, name, payload, attempts, max_attempts, last_error, failed_at)
152
- VALUES ($1, $2, $3, $4, $5, $6, $7)
180
+ // `job_id` is NOT NULL in the table, and the id is derived from the job so a
181
+ // repeated recovery of the same zombie stays idempotent. A failure here is
182
+ // logged: swallowing it is how a dead-letter queue silently holds nothing.
183
+ const dlqId = `dlq_${deadJob.id}`;
184
+
185
+ try {
186
+ await this.db.query(
187
+ `INSERT INTO _synapse_jobs_dlq (id, job_id, name, payload, attempts, max_attempts, last_error, failed_at)
188
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
153
189
  ON CONFLICT (id) DO UPDATE SET attempts = EXCLUDED.attempts, last_error = EXCLUDED.last_error, failed_at = EXCLUDED.failed_at;`,
154
- [deadJob.id, deadJob.name, deadJob.payload, deadJob.attempts, deadJob.max_attempts, deadJob.last_error, now]
155
- )
156
- .catch(() => {});
190
+ [
191
+ dlqId,
192
+ deadJob.id,
193
+ deadJob.name,
194
+ deadJob.payload,
195
+ deadJob.attempts,
196
+ deadJob.max_attempts,
197
+ deadJob.last_error,
198
+ now
199
+ ]
200
+ );
201
+ } catch (error: unknown) {
202
+ this.logger.warn(
203
+ `[DLQ] Falha ao registrar o job ${deadJob.id} na dead letter queue: ${error instanceof Error ? error.message : String(error)}`
204
+ );
205
+ }
157
206
  }
158
207
 
159
208
  const rows = await this.db.query<JobRecord>(
@@ -189,7 +238,8 @@ export class PostgresQueueEngine {
189
238
  }
190
239
 
191
240
  const jobDef = this.registry.get(job.name);
192
- const attempts = Number(job.attempts) + 1;
241
+ // Note: attempts is already incremented in SQL by claimNextJob (`attempts = attempts + 1 RETURNING *`)
242
+ const attempts = Number(job.attempts);
193
243
  const now = Date.now();
194
244
 
195
245
  if (!jobDef) {
@@ -302,6 +352,23 @@ export class PostgresQueueEngine {
302
352
  return true;
303
353
  }
304
354
 
355
+ /** SQLite QueueEngine-compatible alias for inspecting the DLQ. */
356
+ async getDeadLetterJobs(limit = 50): Promise<DlqRecord[]> {
357
+ return this.getDlqJobs(limit);
358
+ }
359
+
360
+ /** SQLite QueueEngine-compatible alias: retries the DLQ entry for a job id. */
361
+ async retryJob(jobId: string): Promise<boolean> {
362
+ await this.initSchema();
363
+ const rows = await this.db.query<{ id: string }>(`SELECT id FROM _synapse_jobs_dlq WHERE job_id = $1`, [jobId]);
364
+
365
+ if (rows.length === 0) {
366
+ return false;
367
+ }
368
+
369
+ return this.retryDlqJob(rows[0].id);
370
+ }
371
+
305
372
  async close(): Promise<void> {
306
373
  // No-op for db instance managed externally
307
374
  }