@ismaelsoilet/synapsejs 1.4.0 → 1.9.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 +108 -10
- package/bin/synapse.ts +263 -218
- 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/context-bench.ts +7 -1
- package/src/compiler/contract.ts +31 -4
- package/src/compiler/db-schema-generator.ts +39 -8
- package/src/compiler/fields-parser.ts +28 -1
- package/src/compiler/impact-analyzer.ts +31 -1
- package/src/compiler/migration-runner.ts +144 -11
- package/src/compiler/optional-coverage.ts +137 -0
- package/src/compiler/oracle-runner.ts +8 -3
- package/src/compiler/scaffolder.ts +174 -3
- package/src/compiler/schema-dag.ts +6 -3
- package/src/compiler/slice-discovery.ts +8 -1
- package/src/compiler/slice-splitter.ts +67 -6
- package/src/compiler/slice-templates.ts +10 -0
- package/src/compiler/standalone-builder.ts +16 -2
- package/src/core/action-context.ts +101 -8
- package/src/core/cache.ts +139 -0
- package/src/core/config.ts +124 -10
- package/src/core/database-client.ts +68 -6
- package/src/core/index.ts +5 -0
- package/src/core/path-guard.ts +105 -0
- package/src/core/postgres-client.ts +6 -0
- package/src/core/postgres-queue.ts +45 -7
- package/src/core/query-builder.ts +75 -60
- package/src/core/session-context.ts +1 -1
- package/src/core/session-token.ts +115 -8
- package/src/core/sqlite-client.ts +26 -1
- package/src/core/storage.ts +61 -24
- package/src/core/topics.ts +84 -0
- package/src/core/webhook-signature.ts +151 -0
- package/src/core/websocket.ts +30 -0
- package/src/index.ts +10 -2
- package/src/mcp/server.ts +78 -173
- package/src/mcp/tools.ts +238 -0
- package/src/project-status.ts +330 -0
- package/src/runtime/discovery-rules.ts +20 -1
- package/src/runtime/event-hub.ts +6 -1
- package/src/runtime/network-guard.ts +182 -0
- package/src/runtime/queue-engine.ts +102 -21
- package/src/runtime/rate-limiter.ts +24 -0
- package/src/runtime/server.ts +1380 -251
- package/src/runtime/uploads.ts +199 -19
- package/src/version.ts +11 -0
- package/templates/starter/.jev.json +1 -1
- package/templates/starter/tsconfig.json +1 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SynapseJS - Database Context & Query Builder Abstraction
|
|
3
3
|
*
|
|
4
|
-
* Provides a clean, type-safe persistence contract without reflection-based ORM bloat
|
|
5
|
-
*
|
|
4
|
+
* Provides a clean, type-safe persistence contract without reflection-based ORM bloat:
|
|
5
|
+
* raw parameterized SQL plus a relational query builder, with no adapter for any other
|
|
6
|
+
* query builder.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
export interface QueryResult<T = unknown> {
|
|
@@ -112,6 +113,24 @@ export interface DatabaseClient {
|
|
|
112
113
|
/**
|
|
113
114
|
* In-memory Mock Database Client for deterministic testing and PBT (Fast-Check).
|
|
114
115
|
*/
|
|
116
|
+
/** Equality and `eq`/`in` matching, which is what the in-memory store can honour honestly. */
|
|
117
|
+
function rowMatchesCondition(row: Record<string, unknown>, where: WhereCondition): boolean {
|
|
118
|
+
return Object.entries(where).every(([column, condition]) => {
|
|
119
|
+
const value = row[column];
|
|
120
|
+
|
|
121
|
+
if (condition !== null && typeof condition === 'object' && 'op' in (condition as Record<string, unknown>)) {
|
|
122
|
+
const { op, val } = condition as { op: string; val: unknown };
|
|
123
|
+
|
|
124
|
+
if (op === 'eq') return value === val;
|
|
125
|
+
if (op === 'in') return Array.isArray(val) && val.includes(value);
|
|
126
|
+
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return value === condition;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
115
134
|
export class MockDatabaseClient implements DatabaseClient {
|
|
116
135
|
private tables: Map<string, Array<Record<string, unknown>>> = new Map();
|
|
117
136
|
private queryHandlers: Array<{
|
|
@@ -228,18 +247,61 @@ export class MockDatabaseClient implements DatabaseClient {
|
|
|
228
247
|
async update<T = unknown>(table: string, data: Record<string, unknown>, where: WhereCondition): Promise<T[]> {
|
|
229
248
|
const { sql, params } = compileUpdate(table, data, where);
|
|
230
249
|
const results = await this.query<T>(sql, params);
|
|
231
|
-
|
|
232
|
-
|
|
250
|
+
|
|
251
|
+
if (results.length > 0) {
|
|
252
|
+
return results;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// No row matched, so no row is returned: reporting the payload back would be a
|
|
256
|
+
// write that never happened.
|
|
257
|
+
const rows = this.tables.get(table);
|
|
258
|
+
|
|
259
|
+
if (!rows) {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const updated = rows.filter((row) => rowMatchesCondition(row, where));
|
|
264
|
+
for (const row of updated) {
|
|
265
|
+
Object.assign(row, data);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return updated as unknown as T[];
|
|
233
269
|
}
|
|
234
270
|
|
|
235
271
|
async delete(table: string, where: WhereCondition): Promise<number> {
|
|
236
272
|
const { sql, params } = compileDelete(table, where);
|
|
237
273
|
const results = await this.query(sql, params);
|
|
238
|
-
|
|
274
|
+
|
|
275
|
+
if (results.length > 0) {
|
|
276
|
+
return results.length;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// A delete that matched nothing removed nothing.
|
|
280
|
+
const rows = this.tables.get(table);
|
|
281
|
+
|
|
282
|
+
if (!rows) {
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const survivors = rows.filter((row) => !rowMatchesCondition(row, where));
|
|
287
|
+
const removed = rows.length - survivors.length;
|
|
288
|
+
this.tables.set(table, survivors);
|
|
289
|
+
|
|
290
|
+
return removed;
|
|
239
291
|
}
|
|
240
292
|
|
|
241
293
|
async transaction<T>(operation: (tx: DatabaseClient) => Promise<T>): Promise<T> {
|
|
242
|
-
|
|
294
|
+
const snapshot = new Map(
|
|
295
|
+
Array.from(this.tables.entries()).map(([table, rows]) => [table, rows.map((row) => ({ ...row }))])
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
return await operation(this);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
// A rolled-back transaction discards the writes made inside it.
|
|
302
|
+
this.tables = snapshot;
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
243
305
|
}
|
|
244
306
|
|
|
245
307
|
close(): void {}
|
package/src/core/index.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
export * from './action-context';
|
|
2
2
|
export * from './action-definition';
|
|
3
|
+
export * from './cache';
|
|
3
4
|
export * from './config';
|
|
4
5
|
export * from './database-client';
|
|
5
6
|
export * from './database-factory';
|
|
6
7
|
export * from './image-optimizer';
|
|
7
8
|
export * from './jobs';
|
|
8
9
|
export * from './machine-types';
|
|
10
|
+
export * from './path-guard';
|
|
9
11
|
export * from './postgres-client';
|
|
10
12
|
export * from './postgres-queue';
|
|
11
13
|
export * from './query-builder';
|
|
@@ -13,4 +15,7 @@ export * from './session-context';
|
|
|
13
15
|
export * from './session-token';
|
|
14
16
|
export * from './sqlite-client';
|
|
15
17
|
export * from './storage';
|
|
18
|
+
export * from './topics';
|
|
16
19
|
export * from './validation';
|
|
20
|
+
export * from './webhook-signature';
|
|
21
|
+
export * from './websocket';
|
|
@@ -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
|
+
}
|
|
@@ -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,47 @@ 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
|
+
// `job_id` is NOT NULL in the table, and the id is derived from the job so a
|
|
150
|
+
// repeated recovery of the same zombie stays idempotent. A failure here is
|
|
151
|
+
// logged: swallowing it is how a dead-letter queue silently holds nothing.
|
|
152
|
+
const dlqId = `dlq_${deadJob.id}`;
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
await this.db.query(
|
|
156
|
+
`INSERT INTO _synapse_jobs_dlq (id, job_id, name, payload, attempts, max_attempts, last_error, failed_at)
|
|
157
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
158
|
+
ON CONFLICT (id) DO UPDATE SET attempts = EXCLUDED.attempts, last_error = EXCLUDED.last_error, failed_at = EXCLUDED.failed_at;`,
|
|
159
|
+
[
|
|
160
|
+
dlqId,
|
|
161
|
+
deadJob.id,
|
|
162
|
+
deadJob.name,
|
|
163
|
+
deadJob.payload,
|
|
164
|
+
deadJob.attempts,
|
|
165
|
+
deadJob.max_attempts,
|
|
166
|
+
deadJob.last_error,
|
|
167
|
+
now
|
|
168
|
+
]
|
|
169
|
+
);
|
|
170
|
+
} catch (error: unknown) {
|
|
171
|
+
this.logger.warn(
|
|
172
|
+
`[DLQ] Falha ao registrar o job ${deadJob.id} na dead letter queue: ${error instanceof Error ? error.message : String(error)}`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
139
177
|
const rows = await this.db.query<JobRecord>(
|
|
140
178
|
`UPDATE _synapse_jobs
|
|
141
|
-
SET status = 'running', locked_at = $1, updated_at = $1
|
|
179
|
+
SET status = 'running', locked_at = $1, updated_at = $1, attempts = attempts + 1
|
|
142
180
|
WHERE id = (
|
|
143
181
|
SELECT id FROM _synapse_jobs
|
|
144
182
|
WHERE (status = 'pending' AND run_at <= $1)
|
|
@@ -201,8 +239,8 @@ export class PostgresQueueEngine {
|
|
|
201
239
|
await this.db.query(
|
|
202
240
|
`UPDATE _synapse_jobs
|
|
203
241
|
SET status = 'completed', attempts = $1, locked_at = NULL, updated_at = $2, last_error = NULL
|
|
204
|
-
WHERE id = $3`,
|
|
205
|
-
[attempts, now, job.id]
|
|
242
|
+
WHERE id = $3 AND (locked_at = $4 OR locked_at IS NULL)`,
|
|
243
|
+
[attempts, now, job.id, job.locked_at]
|
|
206
244
|
);
|
|
207
245
|
return true;
|
|
208
246
|
} catch (handlerErr: unknown) {
|
|
@@ -218,8 +256,8 @@ export class PostgresQueueEngine {
|
|
|
218
256
|
await this.db.query(
|
|
219
257
|
`UPDATE _synapse_jobs
|
|
220
258
|
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]
|
|
259
|
+
WHERE id = $5 AND (locked_at = $6 OR locked_at IS NULL)`,
|
|
260
|
+
[attempts, nextRunAt, now, errorMsg, job.id, job.locked_at]
|
|
223
261
|
);
|
|
224
262
|
} else {
|
|
225
263
|
await this.sendToDlq(job, attempts, `Esgotado limite de tentativas (${job.max_attempts}): ${errorMsg}`, now);
|
|
@@ -244,8 +282,8 @@ export class PostgresQueueEngine {
|
|
|
244
282
|
await this.db.query(
|
|
245
283
|
`UPDATE _synapse_jobs
|
|
246
284
|
SET status = 'dlq', attempts = $1, locked_at = NULL, updated_at = $2, last_error = $3
|
|
247
|
-
WHERE id = $4`,
|
|
248
|
-
[attempts, now, errorMsg, job.id]
|
|
285
|
+
WHERE id = $4 AND (locked_at = $5 OR locked_at IS NULL)`,
|
|
286
|
+
[attempts, now, errorMsg, job.id, job.locked_at]
|
|
249
287
|
);
|
|
250
288
|
|
|
251
289
|
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
|
|