@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
package/src/core/jobs.ts
CHANGED
|
@@ -8,10 +8,35 @@
|
|
|
8
8
|
* });
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { Value } from '@sinclair/typebox/value';
|
|
11
12
|
import type { ActionContext } from './action-context';
|
|
12
13
|
|
|
13
14
|
export type JobHandlerFn<TPayload = unknown> = (payload: TPayload, ctx: ActionContext) => Promise<void>;
|
|
14
15
|
|
|
16
|
+
export type JobScheduleErrorCode = 'CRON_UNSUPPORTED_ENGINE' | 'CONFLICTING_SCHEDULE' | 'INVALID_CRON_PAYLOAD';
|
|
17
|
+
|
|
18
|
+
export class JobScheduleError extends Error {
|
|
19
|
+
readonly code: JobScheduleErrorCode;
|
|
20
|
+
|
|
21
|
+
constructor(code: JobScheduleErrorCode, message: string) {
|
|
22
|
+
super(`${code}: ${message}`);
|
|
23
|
+
this.name = 'JobScheduleError';
|
|
24
|
+
this.code = code;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function jobPayloadMatchesSchema(schema: unknown, payload: unknown): boolean {
|
|
29
|
+
if (!schema || typeof schema !== 'object') {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
return Value.Check(schema as never, payload);
|
|
35
|
+
} catch {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
15
40
|
export interface JobDefinition<TPayload = unknown> {
|
|
16
41
|
/** Unique name identifier for the job */
|
|
17
42
|
readonly name: string;
|
|
@@ -21,6 +46,12 @@ export interface JobDefinition<TPayload = unknown> {
|
|
|
21
46
|
readonly retryLimit?: number;
|
|
22
47
|
/** Initial backoff in seconds for exponential backoff retries (default: 5) */
|
|
23
48
|
readonly backoffSeconds?: number;
|
|
49
|
+
/** Optional recurring cron expression (e.g. '0 0 * * *' or hourly/daily) */
|
|
50
|
+
readonly cron?: string;
|
|
51
|
+
/** Optional payload factory used for the first run and each recurrence. */
|
|
52
|
+
readonly cronPayload?: () => TPayload;
|
|
53
|
+
/** Optional future execution timestamp in milliseconds */
|
|
54
|
+
readonly runAt?: number;
|
|
24
55
|
/** Optional schema validator (e.g. TypeBox schema) */
|
|
25
56
|
readonly schema?: unknown;
|
|
26
57
|
}
|
|
@@ -31,6 +62,9 @@ export interface JobOptions<TPayload = unknown> {
|
|
|
31
62
|
perform?: JobHandlerFn<TPayload>;
|
|
32
63
|
retryLimit?: number;
|
|
33
64
|
backoffSeconds?: number;
|
|
65
|
+
cron?: string;
|
|
66
|
+
cronPayload?: () => TPayload;
|
|
67
|
+
runAt?: number;
|
|
34
68
|
schema?: unknown;
|
|
35
69
|
}
|
|
36
70
|
|
|
@@ -48,6 +82,9 @@ export function defineJob<TPayload = unknown>(options: JobOptions<TPayload>): Jo
|
|
|
48
82
|
handler,
|
|
49
83
|
retryLimit: options.retryLimit ?? 3,
|
|
50
84
|
backoffSeconds: options.backoffSeconds ?? 5,
|
|
85
|
+
cron: options.cron,
|
|
86
|
+
cronPayload: options.cronPayload,
|
|
87
|
+
runAt: options.runAt,
|
|
51
88
|
schema: options.schema
|
|
52
89
|
});
|
|
53
90
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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(
|
|
@@ -207,7 +238,8 @@ export class PostgresQueueEngine {
|
|
|
207
238
|
}
|
|
208
239
|
|
|
209
240
|
const jobDef = this.registry.get(job.name);
|
|
210
|
-
|
|
241
|
+
// Note: attempts is already incremented in SQL by claimNextJob (`attempts = attempts + 1 RETURNING *`)
|
|
242
|
+
const attempts = Number(job.attempts);
|
|
211
243
|
const now = Date.now();
|
|
212
244
|
|
|
213
245
|
if (!jobDef) {
|
|
@@ -320,6 +352,23 @@ export class PostgresQueueEngine {
|
|
|
320
352
|
return true;
|
|
321
353
|
}
|
|
322
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
|
+
|
|
323
372
|
async close(): Promise<void> {
|
|
324
373
|
// No-op for db instance managed externally
|
|
325
374
|
}
|