@ismaelsoilet/synapsejs 1.3.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 +18 -11
- package/src/compiler/slice-splitter.ts +29 -4
- 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/action-definition.ts +89 -0
- package/src/core/cache.ts +139 -0
- package/src/core/config.ts +37 -1
- package/src/core/index.ts +3 -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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - Incremental Static Regeneration (ISR) & SSR Micro-Cache Contract
|
|
3
|
+
*
|
|
4
|
+
* Slices export `sliceCache` or `cacheConfig` to declare caching boundaries,
|
|
5
|
+
* stale-while-revalidate windows, and tag-based invalidation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface SliceCacheConfig {
|
|
9
|
+
/** Time to live in seconds before the cached SSR HTML is considered stale */
|
|
10
|
+
ttlSeconds: number;
|
|
11
|
+
/** Stale-while-revalidate grace period in seconds */
|
|
12
|
+
staleWhileRevalidateSeconds?: number;
|
|
13
|
+
/** Invalidation tags (e.g. ['products', 'catalog']) */
|
|
14
|
+
tags?: string[];
|
|
15
|
+
/** Allowed query parameters to differentiate cache entries (default: all non-tracking parameters) */
|
|
16
|
+
allowedParams?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Declares SSR caching and ISR revalidation policy for a slice.
|
|
21
|
+
*/
|
|
22
|
+
export function defineCache(config: SliceCacheConfig): SliceCacheConfig {
|
|
23
|
+
return config;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_TRACKING_PARAMS = new Set([
|
|
27
|
+
'utm_source',
|
|
28
|
+
'utm_medium',
|
|
29
|
+
'utm_campaign',
|
|
30
|
+
'utm_term',
|
|
31
|
+
'utm_content',
|
|
32
|
+
'fbclid',
|
|
33
|
+
'gclid',
|
|
34
|
+
'msclkid',
|
|
35
|
+
'mc_eid',
|
|
36
|
+
'_ga',
|
|
37
|
+
'_gl',
|
|
38
|
+
'ref',
|
|
39
|
+
'v'
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalizes a URL into a deterministic SSR cache key:
|
|
44
|
+
* - Strips common ad/tracking query parameters (utm_*, fbclid, etc.)
|
|
45
|
+
* - If allowedParams is specified, filters query parameters strictly to that allowlist
|
|
46
|
+
* - Sorts query parameters deterministically to avoid duplicate cache entries for different key orders
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeCacheKey(url: URL, allowedParams?: string[]): string {
|
|
49
|
+
const pathname = url.pathname;
|
|
50
|
+
const searchParams = new URLSearchParams(url.search);
|
|
51
|
+
|
|
52
|
+
const cleanParams = new URLSearchParams();
|
|
53
|
+
const allowedSet = allowedParams ? new Set(allowedParams) : null;
|
|
54
|
+
|
|
55
|
+
const sortedKeys = Array.from(searchParams.keys()).sort();
|
|
56
|
+
for (const key of sortedKeys) {
|
|
57
|
+
if (allowedSet) {
|
|
58
|
+
if (allowedSet.has(key)) {
|
|
59
|
+
for (const val of searchParams.getAll(key)) {
|
|
60
|
+
cleanParams.append(key, val);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} else if (!DEFAULT_TRACKING_PARAMS.has(key.toLowerCase()) && !key.toLowerCase().startsWith('utm_')) {
|
|
64
|
+
for (const val of searchParams.getAll(key)) {
|
|
65
|
+
cleanParams.append(key, val);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const queryStr = cleanParams.toString();
|
|
71
|
+
return queryStr ? `${pathname}?${queryStr}` : pathname;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* In-memory Bounded LRU Cache with O(1) get/set and automatic eviction of least recently used entries.
|
|
76
|
+
*/
|
|
77
|
+
export class BoundedLruCache<V> {
|
|
78
|
+
private map = new Map<string, V>();
|
|
79
|
+
readonly maxEntries: number;
|
|
80
|
+
|
|
81
|
+
constructor(maxEntries = 1000) {
|
|
82
|
+
this.maxEntries = Math.max(1, maxEntries);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get(key: string): V | undefined {
|
|
86
|
+
const val = this.map.get(key);
|
|
87
|
+
if (val !== undefined) {
|
|
88
|
+
// Re-insert to mark as most recently used (MRU)
|
|
89
|
+
this.map.delete(key);
|
|
90
|
+
this.map.set(key, val);
|
|
91
|
+
}
|
|
92
|
+
return val;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
set(key: string, value: V): void {
|
|
96
|
+
if (this.map.has(key)) {
|
|
97
|
+
this.map.delete(key);
|
|
98
|
+
} else if (this.map.size >= this.maxEntries) {
|
|
99
|
+
// Evict least recently used (first key in map iteration order)
|
|
100
|
+
const oldestKey = this.map.keys().next().value;
|
|
101
|
+
if (oldestKey !== undefined) {
|
|
102
|
+
this.map.delete(oldestKey);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
this.map.set(key, value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
has(key: string): boolean {
|
|
109
|
+
return this.map.has(key);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
delete(key: string): boolean {
|
|
113
|
+
return this.map.delete(key);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
clear(): void {
|
|
117
|
+
this.map.clear();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get size(): number {
|
|
121
|
+
return this.map.size;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
entries(): IterableIterator<[string, V]> {
|
|
125
|
+
return this.map.entries();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
values(): IterableIterator<V> {
|
|
129
|
+
return this.map.values();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
keys(): IterableIterator<string> {
|
|
133
|
+
return this.map.keys();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
[Symbol.iterator](): IterableIterator<[string, V]> {
|
|
137
|
+
return this.map[Symbol.iterator]();
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/core/config.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface SynapsePlugin {
|
|
|
11
11
|
name: string;
|
|
12
12
|
// biome-ignore lint/suspicious/noExplicitAny: generic boundary for framework server
|
|
13
13
|
onBootstrap?: (server: any) => Promise<void> | void;
|
|
14
|
-
onRequest?: (req: Request) => Promise<Response | null |
|
|
14
|
+
onRequest?: (req: Request) => Promise<Response | null | undefined> | Response | null | void;
|
|
15
15
|
onResponse?: (res: Response, req: Request) => Promise<Response> | Response;
|
|
16
16
|
// biome-ignore lint/suspicious/noExplicitAny: generic boundary for database client
|
|
17
17
|
onMigrate?: (db: any) => Promise<void> | void;
|
|
@@ -45,6 +45,42 @@ export interface SynapseConfig<TServices = Record<string, unknown>> {
|
|
|
45
45
|
* Infrastructure lifecycle plugins (onBootstrap, onRequest, onResponse, onMigrate).
|
|
46
46
|
*/
|
|
47
47
|
plugins?: SynapsePlugin[];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compiler and client bundler configuration.
|
|
51
|
+
*/
|
|
52
|
+
compiler?: {
|
|
53
|
+
vendorPackages?: string[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Trust proxy headers (cf-connecting-ip, x-forwarded-for) for rate limiting and IP resolution.
|
|
58
|
+
*/
|
|
59
|
+
trustProxy?: boolean;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Max allowed payload bytes for RPC POST requests (default: 5MB).
|
|
63
|
+
*/
|
|
64
|
+
maxRpcPayloadBytes?: number;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Max allowed payload bytes for Webhook POST requests (default: 10MB).
|
|
68
|
+
*/
|
|
69
|
+
maxWebhookPayloadBytes?: number;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* SSR Cache settings.
|
|
73
|
+
*/
|
|
74
|
+
cache?: {
|
|
75
|
+
maxEntries?: number;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Image optimizer security settings.
|
|
80
|
+
*/
|
|
81
|
+
imageOptimizer?: {
|
|
82
|
+
allowedDomains?: string[];
|
|
83
|
+
};
|
|
48
84
|
}
|
|
49
85
|
|
|
50
86
|
export function defineConfig<TServices = Record<string, unknown>>(
|
package/src/core/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export * from './action-context';
|
|
2
|
+
export * from './action-definition';
|
|
3
|
+
export * from './cache';
|
|
2
4
|
export * from './config';
|
|
3
5
|
export * from './database-client';
|
|
4
6
|
export * from './database-factory';
|
|
@@ -13,3 +15,4 @@ export * from './session-token';
|
|
|
13
15
|
export * from './sqlite-client';
|
|
14
16
|
export * from './storage';
|
|
15
17
|
export * from './validation';
|
|
18
|
+
export * from './websocket';
|
|
@@ -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 };
|