@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.
Files changed (38) hide show
  1. package/README.md +15 -8
  2. package/bin/synapse.ts +70 -3
  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/migration-runner.ts +134 -9
  11. package/src/compiler/scaffolder.ts +62 -0
  12. package/src/compiler/schema-dag.ts +18 -11
  13. package/src/compiler/slice-splitter.ts +29 -4
  14. package/src/compiler/slice-templates.ts +10 -0
  15. package/src/compiler/standalone-builder.ts +16 -2
  16. package/src/core/action-context.ts +56 -0
  17. package/src/core/action-definition.ts +89 -0
  18. package/src/core/cache.ts +139 -0
  19. package/src/core/config.ts +37 -1
  20. package/src/core/index.ts +3 -0
  21. package/src/core/postgres-client.ts +6 -0
  22. package/src/core/postgres-queue.ts +27 -7
  23. package/src/core/query-builder.ts +75 -60
  24. package/src/core/session-context.ts +1 -1
  25. package/src/core/session-token.ts +7 -1
  26. package/src/core/sqlite-client.ts +26 -1
  27. package/src/core/storage.ts +1 -5
  28. package/src/core/websocket.ts +30 -0
  29. package/src/index.ts +8 -1
  30. package/src/mcp/server.ts +63 -1
  31. package/src/runtime/discovery-rules.ts +20 -1
  32. package/src/runtime/event-hub.ts +6 -1
  33. package/src/runtime/network-guard.ts +180 -0
  34. package/src/runtime/queue-engine.ts +55 -21
  35. package/src/runtime/rate-limiter.ts +14 -0
  36. package/src/runtime/server.ts +572 -139
  37. package/src/version.ts +4 -0
  38. 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
+ }
@@ -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 | void> | Response | null | void;
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: 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
 
@@ -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.db.run('BEGIN TRANSACTION;');
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();
@@ -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
- if (response.status === 403 || response.status === 404) {
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 { type ScaffoldError, type ScaffoldErrorCode, scaffoldCrud, scaffoldSlice } from './compiler/scaffolder';
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 };