@ismaelsoilet/synapsejs 1.4.0 → 1.8.0
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 +15 -8
- package/bin/synapse.ts +70 -3
- package/package.json +1 -1
- package/src/client/components.tsx +26 -9
- package/src/client/outlet.tsx +40 -0
- package/src/client/sub-slice.tsx +45 -0
- package/src/client/use-websocket.ts +146 -0
- package/src/client.ts +12 -0
- package/src/compiler/client-bundler.ts +17 -7
- package/src/compiler/migration-runner.ts +134 -9
- package/src/compiler/scaffolder.ts +62 -0
- package/src/compiler/schema-dag.ts +6 -3
- package/src/compiler/slice-splitter.ts +20 -3
- package/src/compiler/slice-templates.ts +10 -0
- package/src/compiler/standalone-builder.ts +16 -2
- package/src/core/action-context.ts +56 -0
- package/src/core/cache.ts +139 -0
- package/src/core/config.ts +37 -1
- package/src/core/index.ts +2 -0
- package/src/core/postgres-client.ts +6 -0
- package/src/core/postgres-queue.ts +27 -7
- package/src/core/query-builder.ts +75 -60
- package/src/core/session-context.ts +1 -1
- package/src/core/session-token.ts +7 -1
- package/src/core/sqlite-client.ts +26 -1
- package/src/core/storage.ts +1 -5
- package/src/core/websocket.ts +30 -0
- package/src/index.ts +8 -1
- package/src/mcp/server.ts +63 -1
- package/src/runtime/discovery-rules.ts +20 -1
- package/src/runtime/event-hub.ts +6 -1
- package/src/runtime/network-guard.ts +180 -0
- package/src/runtime/queue-engine.ts +55 -21
- package/src/runtime/rate-limiter.ts +14 -0
- package/src/runtime/server.ts +572 -139
- package/src/version.ts +4 -0
- package/templates/starter/.jev.json +1 -1
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
export class PostgresDatabaseClient implements DatabaseClient {
|
|
22
22
|
private client: postgres.Sql;
|
|
23
23
|
|
|
24
|
+
// biome-ignore lint/suspicious/noExplicitAny: external postgres.js options boundary
|
|
24
25
|
constructor(connectionString: string, options?: postgres.Options<any>) {
|
|
25
26
|
this.client = postgres(connectionString, {
|
|
26
27
|
max: options?.max || 10,
|
|
@@ -34,6 +35,7 @@ export class PostgresDatabaseClient implements DatabaseClient {
|
|
|
34
35
|
|
|
35
36
|
async query<T = unknown>(queryStr: string, params: unknown[] = []): Promise<T[]> {
|
|
36
37
|
// postgres.js unsafe executes raw SQL with parameter bindings
|
|
38
|
+
// biome-ignore lint/suspicious/noExplicitAny: external postgres.js unsafe bindings boundary
|
|
37
39
|
const result = await this.client.unsafe(queryStr, params as any[]);
|
|
38
40
|
return result as unknown as T[];
|
|
39
41
|
}
|
|
@@ -97,20 +99,24 @@ export class PostgresDatabaseClient implements DatabaseClient {
|
|
|
97
99
|
const res = await this.client.begin(async (sqlTx) => {
|
|
98
100
|
const txClient: DatabaseClient = {
|
|
99
101
|
query: async <U = unknown>(q: string, p: unknown[] = []) => {
|
|
102
|
+
// biome-ignore lint/suspicious/noExplicitAny: external postgres.js unsafe bindings boundary
|
|
100
103
|
const res = await sqlTx.unsafe(q, p as any[]);
|
|
101
104
|
return res as unknown as U[];
|
|
102
105
|
},
|
|
103
106
|
queryOne: async <U = unknown>(q: string, p: unknown[] = []) => {
|
|
107
|
+
// biome-ignore lint/suspicious/noExplicitAny: external postgres.js unsafe bindings boundary
|
|
104
108
|
const res = await sqlTx.unsafe(q, p as any[]);
|
|
105
109
|
return (res[0] ?? null) as U | null;
|
|
106
110
|
},
|
|
107
111
|
sql: async <U = unknown>(strings: TemplateStringsArray, ...values: unknown[]) => {
|
|
108
112
|
const { text, params } = compileTaggedSql(strings, ...values);
|
|
113
|
+
// biome-ignore lint/suspicious/noExplicitAny: external postgres.js unsafe bindings boundary
|
|
109
114
|
const res = await sqlTx.unsafe(text, params as any[]);
|
|
110
115
|
return res as unknown as U[];
|
|
111
116
|
},
|
|
112
117
|
sqlOne: async <U = unknown>(strings: TemplateStringsArray, ...values: unknown[]) => {
|
|
113
118
|
const { text, params } = compileTaggedSql(strings, ...values);
|
|
119
|
+
// biome-ignore lint/suspicious/noExplicitAny: external postgres.js unsafe bindings boundary
|
|
114
120
|
const res = await sqlTx.unsafe(text, params as any[]);
|
|
115
121
|
return (res[0] ?? null) as U | null;
|
|
116
122
|
},
|
|
@@ -136,9 +136,29 @@ export class PostgresQueueEngine {
|
|
|
136
136
|
const now = Date.now();
|
|
137
137
|
const staleThreshold = now - this.visibilityTimeoutMs;
|
|
138
138
|
|
|
139
|
+
// 1. Mover tarefas zumbi expiradas para a tabela de DLQ
|
|
140
|
+
const deadJobs = await this.db.query<JobRecord>(
|
|
141
|
+
`UPDATE _synapse_jobs
|
|
142
|
+
SET status = 'dlq', locked_at = NULL, updated_at = $1, last_error = 'Visibility timeout exceeded max attempts'
|
|
143
|
+
WHERE status = 'running' AND locked_at < $2 AND attempts >= max_attempts
|
|
144
|
+
RETURNING *;`,
|
|
145
|
+
[now, staleThreshold]
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
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)
|
|
153
|
+
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(() => {});
|
|
157
|
+
}
|
|
158
|
+
|
|
139
159
|
const rows = await this.db.query<JobRecord>(
|
|
140
160
|
`UPDATE _synapse_jobs
|
|
141
|
-
SET status = 'running', locked_at = $1, updated_at = $1
|
|
161
|
+
SET status = 'running', locked_at = $1, updated_at = $1, attempts = attempts + 1
|
|
142
162
|
WHERE id = (
|
|
143
163
|
SELECT id FROM _synapse_jobs
|
|
144
164
|
WHERE (status = 'pending' AND run_at <= $1)
|
|
@@ -201,8 +221,8 @@ export class PostgresQueueEngine {
|
|
|
201
221
|
await this.db.query(
|
|
202
222
|
`UPDATE _synapse_jobs
|
|
203
223
|
SET status = 'completed', attempts = $1, locked_at = NULL, updated_at = $2, last_error = NULL
|
|
204
|
-
WHERE id = $3`,
|
|
205
|
-
[attempts, now, job.id]
|
|
224
|
+
WHERE id = $3 AND (locked_at = $4 OR locked_at IS NULL)`,
|
|
225
|
+
[attempts, now, job.id, job.locked_at]
|
|
206
226
|
);
|
|
207
227
|
return true;
|
|
208
228
|
} catch (handlerErr: unknown) {
|
|
@@ -218,8 +238,8 @@ export class PostgresQueueEngine {
|
|
|
218
238
|
await this.db.query(
|
|
219
239
|
`UPDATE _synapse_jobs
|
|
220
240
|
SET status = 'pending', attempts = $1, run_at = $2, locked_at = NULL, updated_at = $3, last_error = $4
|
|
221
|
-
WHERE id = $5`,
|
|
222
|
-
[attempts, nextRunAt, now, errorMsg, job.id]
|
|
241
|
+
WHERE id = $5 AND (locked_at = $6 OR locked_at IS NULL)`,
|
|
242
|
+
[attempts, nextRunAt, now, errorMsg, job.id, job.locked_at]
|
|
223
243
|
);
|
|
224
244
|
} else {
|
|
225
245
|
await this.sendToDlq(job, attempts, `Esgotado limite de tentativas (${job.max_attempts}): ${errorMsg}`, now);
|
|
@@ -244,8 +264,8 @@ export class PostgresQueueEngine {
|
|
|
244
264
|
await this.db.query(
|
|
245
265
|
`UPDATE _synapse_jobs
|
|
246
266
|
SET status = 'dlq', attempts = $1, locked_at = NULL, updated_at = $2, last_error = $3
|
|
247
|
-
WHERE id = $4`,
|
|
248
|
-
[attempts, now, errorMsg, job.id]
|
|
267
|
+
WHERE id = $4 AND (locked_at = $5 OR locked_at IS NULL)`,
|
|
268
|
+
[attempts, now, errorMsg, job.id, job.locked_at]
|
|
249
269
|
);
|
|
250
270
|
|
|
251
271
|
this.logger.warn(`[DLQ] Job ${job.name} (${job.id}) movido para Dead Letter Queue: ${dlqId}`);
|
|
@@ -56,6 +56,31 @@ export function sanitizeIdentifier(ident: string): string {
|
|
|
56
56
|
return trimmed;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
const VALID_WHERE_OPERATORS = new Set<string>([
|
|
60
|
+
'=',
|
|
61
|
+
'!=',
|
|
62
|
+
'<>',
|
|
63
|
+
'>',
|
|
64
|
+
'<',
|
|
65
|
+
'>=',
|
|
66
|
+
'<=',
|
|
67
|
+
'LIKE',
|
|
68
|
+
'ILIKE',
|
|
69
|
+
'IN',
|
|
70
|
+
'NOT IN',
|
|
71
|
+
'IS NULL',
|
|
72
|
+
'IS NOT NULL',
|
|
73
|
+
'BETWEEN'
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
export function sanitizeOperator(op: string): WhereOperator {
|
|
77
|
+
const normalized = op.trim().toUpperCase();
|
|
78
|
+
if (!VALID_WHERE_OPERATORS.has(normalized)) {
|
|
79
|
+
throw new Error(`Invalid SQL operator: "${op}"`);
|
|
80
|
+
}
|
|
81
|
+
return normalized as WhereOperator;
|
|
82
|
+
}
|
|
83
|
+
|
|
59
84
|
/** Normalize WhereCondition into an array of WhereClauseItem */
|
|
60
85
|
function normalizeWhere(where?: WhereCondition): WhereClauseItem[] {
|
|
61
86
|
if (!where) return [];
|
|
@@ -70,6 +95,13 @@ function normalizeWhere(where?: WhereCondition): WhereClauseItem[] {
|
|
|
70
95
|
items.push({ column, op: 'IS NULL' });
|
|
71
96
|
} else if (Array.isArray(value)) {
|
|
72
97
|
items.push({ column, op: 'IN', value });
|
|
98
|
+
} else if (typeof value === 'object' && value !== null && 'op' in value) {
|
|
99
|
+
const cond = value as { op: string; val?: unknown; value?: unknown };
|
|
100
|
+
items.push({
|
|
101
|
+
column,
|
|
102
|
+
op: cond.op as WhereOperator,
|
|
103
|
+
value: cond.val !== undefined ? cond.val : cond.value
|
|
104
|
+
});
|
|
73
105
|
} else {
|
|
74
106
|
items.push({ column, op: '=', value });
|
|
75
107
|
}
|
|
@@ -77,6 +109,46 @@ function normalizeWhere(where?: WhereCondition): WhereClauseItem[] {
|
|
|
77
109
|
return items;
|
|
78
110
|
}
|
|
79
111
|
|
|
112
|
+
/** Compiles WhereClauseItem array into parameterized SQL condition strings */
|
|
113
|
+
export function compileWhereClauses(whereItems: WhereClauseItem[], params: unknown[]): string[] {
|
|
114
|
+
const conditions: string[] = [];
|
|
115
|
+
|
|
116
|
+
for (const item of whereItems) {
|
|
117
|
+
const col = sanitizeIdentifier(item.column);
|
|
118
|
+
const op = sanitizeOperator(item.op);
|
|
119
|
+
|
|
120
|
+
if (op === 'IS NULL' || op === 'IS NOT NULL') {
|
|
121
|
+
conditions.push(`${col} ${op}`);
|
|
122
|
+
} else if (op === 'IN' || op === 'NOT IN') {
|
|
123
|
+
const arr = Array.isArray(item.value) ? item.value : [item.value];
|
|
124
|
+
if (arr.length === 0) {
|
|
125
|
+
conditions.push(op === 'IN' ? '1=0' : '1=1');
|
|
126
|
+
} else {
|
|
127
|
+
const placeholders = arr.map((val) => {
|
|
128
|
+
params.push(val);
|
|
129
|
+
return `$${params.length}`;
|
|
130
|
+
});
|
|
131
|
+
conditions.push(`${col} ${op} (${placeholders.join(', ')})`);
|
|
132
|
+
}
|
|
133
|
+
} else if (op === 'BETWEEN') {
|
|
134
|
+
const arr = Array.isArray(item.value) ? item.value : [];
|
|
135
|
+
if (arr.length !== 2) {
|
|
136
|
+
throw new Error(`BETWEEN operator requires an array of 2 values, received: ${JSON.stringify(item.value)}`);
|
|
137
|
+
}
|
|
138
|
+
params.push(arr[0]);
|
|
139
|
+
const p1 = `$${params.length}`;
|
|
140
|
+
params.push(arr[1]);
|
|
141
|
+
const p2 = `$${params.length}`;
|
|
142
|
+
conditions.push(`${col} BETWEEN ${p1} AND ${p2}`);
|
|
143
|
+
} else {
|
|
144
|
+
params.push(item.value);
|
|
145
|
+
conditions.push(`${col} ${op} $${params.length}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return conditions;
|
|
150
|
+
}
|
|
151
|
+
|
|
80
152
|
/**
|
|
81
153
|
* Maps flat prefixed columns (e.g. "customer_name") into nested objects ({ customer: { name } })
|
|
82
154
|
*/
|
|
@@ -174,42 +246,7 @@ export function compileSelect(table: string, options?: QueryOptions): { sql: str
|
|
|
174
246
|
// 2. WHERE clause
|
|
175
247
|
const whereItems = normalizeWhere(options?.where);
|
|
176
248
|
if (whereItems.length > 0) {
|
|
177
|
-
const conditions
|
|
178
|
-
|
|
179
|
-
for (const item of whereItems) {
|
|
180
|
-
const col = sanitizeIdentifier(item.column);
|
|
181
|
-
const op = item.op.toUpperCase() as WhereOperator;
|
|
182
|
-
|
|
183
|
-
if (op === 'IS NULL' || op === 'IS NOT NULL') {
|
|
184
|
-
conditions.push(`${col} ${op}`);
|
|
185
|
-
} else if (op === 'IN' || op === 'NOT IN') {
|
|
186
|
-
const arr = Array.isArray(item.value) ? item.value : [item.value];
|
|
187
|
-
if (arr.length === 0) {
|
|
188
|
-
// Empty IN clause: IN () is invalid SQL -> false / true condition
|
|
189
|
-
conditions.push(op === 'IN' ? '1=0' : '1=1');
|
|
190
|
-
} else {
|
|
191
|
-
const placeholders = arr.map((val) => {
|
|
192
|
-
params.push(val);
|
|
193
|
-
return `$${params.length}`;
|
|
194
|
-
});
|
|
195
|
-
conditions.push(`${col} ${op} (${placeholders.join(', ')})`);
|
|
196
|
-
}
|
|
197
|
-
} else if (op === 'BETWEEN') {
|
|
198
|
-
const arr = Array.isArray(item.value) ? item.value : [];
|
|
199
|
-
if (arr.length !== 2) {
|
|
200
|
-
throw new Error(`BETWEEN operator requires an array of 2 values, received: ${JSON.stringify(item.value)}`);
|
|
201
|
-
}
|
|
202
|
-
params.push(arr[0]);
|
|
203
|
-
const p1 = `$${params.length}`;
|
|
204
|
-
params.push(arr[1]);
|
|
205
|
-
const p2 = `$${params.length}`;
|
|
206
|
-
conditions.push(`${col} BETWEEN ${p1} AND ${p2}`);
|
|
207
|
-
} else {
|
|
208
|
-
params.push(item.value);
|
|
209
|
-
conditions.push(`${col} ${op} $${params.length}`);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
249
|
+
const conditions = compileWhereClauses(whereItems, params);
|
|
213
250
|
if (conditions.length > 0) {
|
|
214
251
|
sql += ` WHERE ${conditions.join(' AND ')}`;
|
|
215
252
|
}
|
|
@@ -299,18 +336,7 @@ export function compileUpdate(
|
|
|
299
336
|
throw new Error(`Unconditional UPDATE on ${table} is blocked for safety. Provide a WHERE condition.`);
|
|
300
337
|
}
|
|
301
338
|
|
|
302
|
-
const conditions
|
|
303
|
-
for (const item of whereItems) {
|
|
304
|
-
const col = sanitizeIdentifier(item.column);
|
|
305
|
-
const op = item.op.toUpperCase() as WhereOperator;
|
|
306
|
-
if (op === 'IS NULL' || op === 'IS NOT NULL') {
|
|
307
|
-
conditions.push(`${col} ${op}`);
|
|
308
|
-
} else {
|
|
309
|
-
params.push(item.value);
|
|
310
|
-
conditions.push(`${col} ${op} $${params.length}`);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
339
|
+
const conditions = compileWhereClauses(whereItems, params);
|
|
314
340
|
sql += ` WHERE ${conditions.join(' AND ')} RETURNING *;`;
|
|
315
341
|
return { sql, params };
|
|
316
342
|
}
|
|
@@ -326,18 +352,7 @@ export function compileDelete(table: string, where: WhereCondition): { sql: stri
|
|
|
326
352
|
}
|
|
327
353
|
|
|
328
354
|
const params: unknown[] = [];
|
|
329
|
-
const conditions
|
|
330
|
-
for (const item of whereItems) {
|
|
331
|
-
const col = sanitizeIdentifier(item.column);
|
|
332
|
-
const op = item.op.toUpperCase() as WhereOperator;
|
|
333
|
-
if (op === 'IS NULL' || op === 'IS NOT NULL') {
|
|
334
|
-
conditions.push(`${col} ${op}`);
|
|
335
|
-
} else {
|
|
336
|
-
params.push(item.value);
|
|
337
|
-
conditions.push(`${col} ${op} $${params.length}`);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
355
|
+
const conditions = compileWhereClauses(whereItems, params);
|
|
341
356
|
const sql = `DELETE FROM ${safeTable} WHERE ${conditions.join(' AND ')} RETURNING *;`;
|
|
342
357
|
return { sql, params };
|
|
343
358
|
}
|
|
@@ -79,7 +79,7 @@ export function requireTenant(session?: SessionContext, expectedTenantId?: strin
|
|
|
79
79
|
return Err('UNAUTHORIZED');
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
if (expectedTenantId && session.tenantId
|
|
82
|
+
if (expectedTenantId && session.tenantId !== expectedTenantId) {
|
|
83
83
|
return Err('FORBIDDEN');
|
|
84
84
|
}
|
|
85
85
|
|
|
@@ -17,6 +17,7 @@ import { Err, Ok, type Result } from './machine-types';
|
|
|
17
17
|
export interface SessionClaims {
|
|
18
18
|
userId: string;
|
|
19
19
|
roles: string[];
|
|
20
|
+
tenantId?: string;
|
|
20
21
|
/** Expiry, in seconds since the epoch. */
|
|
21
22
|
exp: number;
|
|
22
23
|
}
|
|
@@ -118,7 +119,7 @@ function constantTimeEquals(left: string, right: string): boolean {
|
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
export function signSessionToken(
|
|
121
|
-
claims: { userId: string; roles: string[] },
|
|
122
|
+
claims: { userId: string; roles: string[]; tenantId?: string },
|
|
122
123
|
secret: string,
|
|
123
124
|
maxAgeSeconds = 86_400
|
|
124
125
|
): string {
|
|
@@ -126,6 +127,7 @@ export function signSessionToken(
|
|
|
126
127
|
JSON.stringify({
|
|
127
128
|
userId: claims.userId,
|
|
128
129
|
roles: claims.roles,
|
|
130
|
+
tenantId: claims.tenantId,
|
|
129
131
|
exp: Math.floor(Date.now() / 1000) + maxAgeSeconds
|
|
130
132
|
})
|
|
131
133
|
);
|
|
@@ -162,6 +164,10 @@ export function verifySessionToken(
|
|
|
162
164
|
return Err('INVALID_TOKEN');
|
|
163
165
|
}
|
|
164
166
|
|
|
167
|
+
if (claims.tenantId !== undefined && typeof claims.tenantId !== 'string') {
|
|
168
|
+
return Err('INVALID_TOKEN');
|
|
169
|
+
}
|
|
170
|
+
|
|
165
171
|
if (claims.exp * 1000 < Date.now()) {
|
|
166
172
|
return Err('EXPIRED_TOKEN');
|
|
167
173
|
}
|
|
@@ -38,6 +38,7 @@ export class SqliteDatabaseClient implements DatabaseClient {
|
|
|
38
38
|
private statements: Map<string, Statement<unknown, SQLQueryBindings[]>> = new Map();
|
|
39
39
|
private maxStatements: number;
|
|
40
40
|
private txLock: Promise<void> = Promise.resolve();
|
|
41
|
+
private txDepth = 0;
|
|
41
42
|
|
|
42
43
|
constructor(filePath?: string, maxStatements = 500) {
|
|
43
44
|
this.maxStatements = maxStatements;
|
|
@@ -53,6 +54,11 @@ export class SqliteDatabaseClient implements DatabaseClient {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
this.db.run('PRAGMA journal_mode = WAL;');
|
|
57
|
+
this.db.run('PRAGMA synchronous = NORMAL;');
|
|
58
|
+
this.db.run('PRAGMA busy_timeout = 5000;');
|
|
59
|
+
this.db.run('PRAGMA temp_store = MEMORY;');
|
|
60
|
+
this.db.run('PRAGMA cache_size = -64000;');
|
|
61
|
+
this.db.run('PRAGMA mmap_size = 268435456;');
|
|
56
62
|
this.db.run('PRAGMA foreign_keys = ON;');
|
|
57
63
|
}
|
|
58
64
|
|
|
@@ -161,6 +167,22 @@ export class SqliteDatabaseClient implements DatabaseClient {
|
|
|
161
167
|
}
|
|
162
168
|
|
|
163
169
|
async transaction<T>(operation: (tx: DatabaseClient) => Promise<T>): Promise<T> {
|
|
170
|
+
if (this.txDepth > 0) {
|
|
171
|
+
// Reentrant transaction: use SQLite SAVEPOINT
|
|
172
|
+
const spId = `sp_${++this.txDepth}`;
|
|
173
|
+
this.db.run(`SAVEPOINT ${spId};`);
|
|
174
|
+
try {
|
|
175
|
+
const result = await operation(this);
|
|
176
|
+
this.db.run(`RELEASE SAVEPOINT ${spId};`);
|
|
177
|
+
return result;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
this.db.run(`ROLLBACK TO SAVEPOINT ${spId};`);
|
|
180
|
+
throw err;
|
|
181
|
+
} finally {
|
|
182
|
+
this.txDepth--;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
164
186
|
let releaseLock: () => void = () => {};
|
|
165
187
|
const lockAcquired = new Promise<void>((resolve) => {
|
|
166
188
|
releaseLock = resolve;
|
|
@@ -172,7 +194,8 @@ export class SqliteDatabaseClient implements DatabaseClient {
|
|
|
172
194
|
await previousLock;
|
|
173
195
|
|
|
174
196
|
try {
|
|
175
|
-
this.
|
|
197
|
+
this.txDepth = 1;
|
|
198
|
+
this.db.run('BEGIN IMMEDIATE TRANSACTION;');
|
|
176
199
|
try {
|
|
177
200
|
const result = await operation(this);
|
|
178
201
|
this.db.run('COMMIT;');
|
|
@@ -180,6 +203,8 @@ export class SqliteDatabaseClient implements DatabaseClient {
|
|
|
180
203
|
} catch (err) {
|
|
181
204
|
this.db.run('ROLLBACK;');
|
|
182
205
|
throw err;
|
|
206
|
+
} finally {
|
|
207
|
+
this.txDepth = 0;
|
|
183
208
|
}
|
|
184
209
|
} finally {
|
|
185
210
|
releaseLock();
|
package/src/core/storage.ts
CHANGED
|
@@ -219,11 +219,7 @@ export class S3StorageAdapter implements StorageClient {
|
|
|
219
219
|
});
|
|
220
220
|
|
|
221
221
|
if (!response.ok && response.status !== 200 && response.status !== 204) {
|
|
222
|
-
|
|
223
|
-
// Fallback for mock/test environments
|
|
224
|
-
} else {
|
|
225
|
-
throw new Error(`Storage upload failed with HTTP status ${response.status}`);
|
|
226
|
-
}
|
|
222
|
+
throw new Error(`Storage upload failed with HTTP status ${response.status}`);
|
|
227
223
|
}
|
|
228
224
|
|
|
229
225
|
return {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - Native Bidirectional WebSocket Abstraction
|
|
3
|
+
*
|
|
4
|
+
* Provides a type-safe contract for real-time duplex communication
|
|
5
|
+
* seamlessly integrated with native Bun.serve WebSockets.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface SynapseWebSocket<TServerMsg = unknown> {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly sliceKey: string;
|
|
11
|
+
send(message: TServerMsg | string): void;
|
|
12
|
+
close(code?: number, reason?: string): void;
|
|
13
|
+
data: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SliceSocketDefinition<TClientMsg = unknown, TServerMsg = unknown> {
|
|
17
|
+
onOpen?: (ws: SynapseWebSocket<TServerMsg>) => void | Promise<void>;
|
|
18
|
+
onMessage: (ws: SynapseWebSocket<TServerMsg>, message: TClientMsg) => void | Promise<void>;
|
|
19
|
+
onClose?: (ws: SynapseWebSocket<TServerMsg>, code: number, reason: string) => void | Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Declares a bidirectional WebSocket endpoint for a slice.
|
|
24
|
+
* Automatically discovered and mounted at `GET /_synapse/ws/:domain/:name`.
|
|
25
|
+
*/
|
|
26
|
+
export function defineSocket<TClientMsg = unknown, TServerMsg = unknown>(
|
|
27
|
+
def: SliceSocketDefinition<TClientMsg, TServerMsg>
|
|
28
|
+
): SliceSocketDefinition<TClientMsg, TServerMsg> {
|
|
29
|
+
return def;
|
|
30
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -65,7 +65,13 @@ export {
|
|
|
65
65
|
parseOracleJunit,
|
|
66
66
|
runSliceOracles
|
|
67
67
|
} from './compiler/oracle-runner';
|
|
68
|
-
export {
|
|
68
|
+
export {
|
|
69
|
+
type ScaffoldError,
|
|
70
|
+
type ScaffoldErrorCode,
|
|
71
|
+
scaffoldCrud,
|
|
72
|
+
scaffoldShared,
|
|
73
|
+
scaffoldSlice
|
|
74
|
+
} from './compiler/scaffolder';
|
|
69
75
|
export { orderSlicesByDag, parseTableDependencies } from './compiler/schema-dag';
|
|
70
76
|
export {
|
|
71
77
|
checkSchemaDrift,
|
|
@@ -130,4 +136,5 @@ export {
|
|
|
130
136
|
type SliceMetadata,
|
|
131
137
|
SynapseServer
|
|
132
138
|
} from './runtime/server';
|
|
139
|
+
export { SYNAPSE_VERSION } from './version';
|
|
133
140
|
export { fc };
|
package/src/mcp/server.ts
CHANGED
|
@@ -8,9 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
10
|
type AbortGateResult,
|
|
11
|
+
type ModelRouteResult,
|
|
11
12
|
modulateReasoningEffort,
|
|
13
|
+
type NudgeGateResult,
|
|
12
14
|
type ReasoningEffortResult,
|
|
15
|
+
routeModelTier,
|
|
13
16
|
shouldAbortTrajectory,
|
|
17
|
+
shouldNudgeContinuation,
|
|
14
18
|
type TestTriageResult,
|
|
15
19
|
triageTestFailure,
|
|
16
20
|
type VerificationResult,
|
|
@@ -28,6 +32,7 @@ import { type SliceTemplate, scaffoldCrud, scaffoldSlice } from '../compiler/sca
|
|
|
28
32
|
import { checkSchemaDrift } from '../compiler/schema-drift';
|
|
29
33
|
import { findSliceFiles, resolveSlicesDir } from '../compiler/slice-discovery';
|
|
30
34
|
import { artifactDirectory, splitSlice, verifySplit, writeSplitArtifacts } from '../compiler/slice-splitter';
|
|
35
|
+
import { SYNAPSE_VERSION } from '../version';
|
|
31
36
|
|
|
32
37
|
interface JsonRpcRequest {
|
|
33
38
|
jsonrpc: string;
|
|
@@ -90,7 +95,7 @@ export class SynapseMcpServer {
|
|
|
90
95
|
},
|
|
91
96
|
serverInfo: {
|
|
92
97
|
name: 'synapse-mcp',
|
|
93
|
-
version:
|
|
98
|
+
version: SYNAPSE_VERSION
|
|
94
99
|
}
|
|
95
100
|
}
|
|
96
101
|
};
|
|
@@ -272,6 +277,51 @@ export class SynapseMcpServer {
|
|
|
272
277
|
}
|
|
273
278
|
}
|
|
274
279
|
}
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
name: 'synapse_triage_error',
|
|
283
|
+
description:
|
|
284
|
+
'Triage an arbitrary test failure or execution error log using Jev System One to classify root causes (env_missing vs flaky_transient vs deep_logic) and avoid token waste',
|
|
285
|
+
inputSchema: {
|
|
286
|
+
type: 'object',
|
|
287
|
+
required: ['failureLog'],
|
|
288
|
+
properties: {
|
|
289
|
+
failureLog: {
|
|
290
|
+
type: 'string',
|
|
291
|
+
description: 'Error log, stack trace or terminal output to triage'
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
name: 'synapse_route_task',
|
|
298
|
+
description:
|
|
299
|
+
'Semantically route a development task to the optimal model/reasoning tier via Jev System One',
|
|
300
|
+
inputSchema: {
|
|
301
|
+
type: 'object',
|
|
302
|
+
required: ['task'],
|
|
303
|
+
properties: {
|
|
304
|
+
task: {
|
|
305
|
+
type: 'string',
|
|
306
|
+
description: 'Description of the coding, refactoring, or diagnostic task'
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
name: 'synapse_evaluate_nudge',
|
|
313
|
+
description:
|
|
314
|
+
'Evaluate whether an agent is prematurely stopping or needs a proactive nudge to continue verification',
|
|
315
|
+
inputSchema: {
|
|
316
|
+
type: 'object',
|
|
317
|
+
required: ['transcriptTail'],
|
|
318
|
+
properties: {
|
|
319
|
+
transcriptTail: {
|
|
320
|
+
type: 'string',
|
|
321
|
+
description: 'Recent transcript steps or conversation history tail'
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
275
325
|
}
|
|
276
326
|
]
|
|
277
327
|
}
|
|
@@ -597,6 +647,18 @@ export class SynapseMcpServer {
|
|
|
597
647
|
sessionContextTokens
|
|
598
648
|
});
|
|
599
649
|
contentText = JSON.stringify(result, null, 2);
|
|
650
|
+
} else if (toolName === 'synapse_triage_error') {
|
|
651
|
+
const failureLog = String(args.failureLog || '');
|
|
652
|
+
const result: TestTriageResult = await triageTestFailure(failureLog);
|
|
653
|
+
contentText = JSON.stringify(result, null, 2);
|
|
654
|
+
} else if (toolName === 'synapse_route_task') {
|
|
655
|
+
const task = String(args.task || '');
|
|
656
|
+
const result: ModelRouteResult = await routeModelTier(task);
|
|
657
|
+
contentText = JSON.stringify(result, null, 2);
|
|
658
|
+
} else if (toolName === 'synapse_evaluate_nudge') {
|
|
659
|
+
const transcriptTail = String(args.transcriptTail || '');
|
|
660
|
+
const result: NudgeGateResult = await shouldNudgeContinuation(transcriptTail);
|
|
661
|
+
contentText = JSON.stringify(result, null, 2);
|
|
600
662
|
} else {
|
|
601
663
|
return {
|
|
602
664
|
jsonrpc: '2.0',
|
|
@@ -13,7 +13,11 @@ export const WEBHOOK_SUFFIX = 'Webhook';
|
|
|
13
13
|
export const LOADER_SUFFIX = 'Loader';
|
|
14
14
|
export const SCHEMA_SUFFIX = 'Schema';
|
|
15
15
|
export const META_SUFFIX = 'Meta';
|
|
16
|
+
export const CACHE_SUFFIX = 'Cache';
|
|
17
|
+
export const SOCKET_SUFFIX = 'Socket';
|
|
16
18
|
export const SLICE_META_EXPORT = 'sliceMeta';
|
|
19
|
+
export const SLICE_CACHE_EXPORT = 'sliceCache';
|
|
20
|
+
export const SLICE_SOCKET_EXPORT = 'sliceSocket';
|
|
17
21
|
export const SLICE_DDL_EXPORT = 'sliceSchema';
|
|
18
22
|
export const SLICE_ORACLE_EXPORT = 'sliceTests';
|
|
19
23
|
|
|
@@ -21,7 +25,14 @@ export const SLICE_ORACLE_EXPORT = 'sliceTests';
|
|
|
21
25
|
export const COMPONENT_SUFFIXES = ['Trigger', 'View', 'Form', 'Component'] as const;
|
|
22
26
|
|
|
23
27
|
/** Everything the server side of a slice is built from. */
|
|
24
|
-
export const SERVER_ROOT_SUFFIXES = [
|
|
28
|
+
export const SERVER_ROOT_SUFFIXES = [
|
|
29
|
+
ACTION_SUFFIX,
|
|
30
|
+
JOB_SUFFIX,
|
|
31
|
+
WEBHOOK_SUFFIX,
|
|
32
|
+
META_SUFFIX,
|
|
33
|
+
CACHE_SUFFIX,
|
|
34
|
+
SOCKET_SUFFIX
|
|
35
|
+
] as const;
|
|
25
36
|
|
|
26
37
|
/** Test-only exports, dropped from both runtime bundles. */
|
|
27
38
|
export const TEST_ONLY_EXPORTS = [SLICE_ORACLE_EXPORT] as const;
|
|
@@ -58,6 +69,14 @@ export function isMeta(name: string): boolean {
|
|
|
58
69
|
return name === SLICE_META_EXPORT || name.endsWith(META_SUFFIX);
|
|
59
70
|
}
|
|
60
71
|
|
|
72
|
+
export function isCache(name: string): boolean {
|
|
73
|
+
return name === SLICE_CACHE_EXPORT || name.endsWith(CACHE_SUFFIX);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function isSocket(name: string): boolean {
|
|
77
|
+
return name === SLICE_SOCKET_EXPORT || name.endsWith(SOCKET_SUFFIX);
|
|
78
|
+
}
|
|
79
|
+
|
|
61
80
|
export function isTestOnly(name: string): boolean {
|
|
62
81
|
return hasSuffix(name, TEST_ONLY_EXPORTS);
|
|
63
82
|
}
|
package/src/runtime/event-hub.ts
CHANGED
|
@@ -46,7 +46,12 @@ export class EventHub {
|
|
|
46
46
|
let notified = 0;
|
|
47
47
|
for (const listener of listeners) {
|
|
48
48
|
try {
|
|
49
|
-
listener(data);
|
|
49
|
+
const result: unknown = listener(data);
|
|
50
|
+
if (result && typeof (result as { catch?: unknown }).catch === 'function') {
|
|
51
|
+
(result as { catch: (fn: (err: unknown) => void) => void }).catch((err: unknown) => {
|
|
52
|
+
console.error(`[EventHub] Erro assíncrono no listener do tópico "${topic}":`, err);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
50
55
|
notified++;
|
|
51
56
|
} catch (err) {
|
|
52
57
|
console.error(`[EventHub] Erro no listener do tópico "${topic}":`, err);
|