@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.
Files changed (53) hide show
  1. package/README.md +108 -10
  2. package/bin/synapse.ts +263 -218
  3. package/package.json +1 -1
  4. package/src/client/components.tsx +26 -9
  5. package/src/client/outlet.tsx +40 -0
  6. package/src/client/sub-slice.tsx +45 -0
  7. package/src/client/use-websocket.ts +146 -0
  8. package/src/client.ts +12 -0
  9. package/src/compiler/client-bundler.ts +17 -7
  10. package/src/compiler/context-bench.ts +7 -1
  11. package/src/compiler/contract.ts +31 -4
  12. package/src/compiler/db-schema-generator.ts +39 -8
  13. package/src/compiler/fields-parser.ts +28 -1
  14. package/src/compiler/impact-analyzer.ts +31 -1
  15. package/src/compiler/migration-runner.ts +144 -11
  16. package/src/compiler/optional-coverage.ts +137 -0
  17. package/src/compiler/oracle-runner.ts +8 -3
  18. package/src/compiler/scaffolder.ts +174 -3
  19. package/src/compiler/schema-dag.ts +6 -3
  20. package/src/compiler/slice-discovery.ts +8 -1
  21. package/src/compiler/slice-splitter.ts +67 -6
  22. package/src/compiler/slice-templates.ts +10 -0
  23. package/src/compiler/standalone-builder.ts +16 -2
  24. package/src/core/action-context.ts +101 -8
  25. package/src/core/cache.ts +139 -0
  26. package/src/core/config.ts +124 -10
  27. package/src/core/database-client.ts +68 -6
  28. package/src/core/index.ts +5 -0
  29. package/src/core/path-guard.ts +105 -0
  30. package/src/core/postgres-client.ts +6 -0
  31. package/src/core/postgres-queue.ts +45 -7
  32. package/src/core/query-builder.ts +75 -60
  33. package/src/core/session-context.ts +1 -1
  34. package/src/core/session-token.ts +115 -8
  35. package/src/core/sqlite-client.ts +26 -1
  36. package/src/core/storage.ts +61 -24
  37. package/src/core/topics.ts +84 -0
  38. package/src/core/webhook-signature.ts +151 -0
  39. package/src/core/websocket.ts +30 -0
  40. package/src/index.ts +10 -2
  41. package/src/mcp/server.ts +78 -173
  42. package/src/mcp/tools.ts +238 -0
  43. package/src/project-status.ts +330 -0
  44. package/src/runtime/discovery-rules.ts +20 -1
  45. package/src/runtime/event-hub.ts +6 -1
  46. package/src/runtime/network-guard.ts +182 -0
  47. package/src/runtime/queue-engine.ts +102 -21
  48. package/src/runtime/rate-limiter.ts +24 -0
  49. package/src/runtime/server.ts +1380 -251
  50. package/src/runtime/uploads.ts +199 -19
  51. package/src/version.ts +11 -0
  52. package/templates/starter/.jev.json +1 -1
  53. 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
- * Compatible with raw parameterized SQL, Kysely, and Drizzle query builders.
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
- if (results.length > 0) return results;
232
- return [data as T];
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
- return results.length > 0 ? results.length : 1;
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
- return operation(this);
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: string[] = [];
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: string[] = [];
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: string[] = [];
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 && session.tenantId !== expectedTenantId) {
82
+ if (expectedTenantId && session.tenantId !== expectedTenantId) {
83
83
  return Err('FORBIDDEN');
84
84
  }
85
85