@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
@@ -63,14 +63,114 @@ export interface RollbackOptions {
63
63
  }
64
64
 
65
65
  export function parseBidirectionalDdl(rawDdl: string): BidirectionalDdl {
66
- const downMarkerRegex = /^[ \t]*--\s*down:?[ \t]*$/im;
67
- const match = downMarkerRegex.exec(rawDdl);
68
- if (!match) {
66
+ let inSingleQuote = false;
67
+ let inDoubleQuote = false;
68
+ let inBlockComment = false;
69
+ let dollarTag: string | null = null;
70
+ let splitIndex = -1;
71
+ let matchLength = 0;
72
+
73
+ let i = 0;
74
+ while (i < rawDdl.length) {
75
+ const char = rawDdl[i];
76
+ const next = rawDdl[i + 1] ?? '';
77
+
78
+ if (inBlockComment) {
79
+ if (char === '*' && next === '/') {
80
+ inBlockComment = false;
81
+ i += 2;
82
+ continue;
83
+ }
84
+ i++;
85
+ continue;
86
+ }
87
+
88
+ if (inSingleQuote) {
89
+ if (char === '\\') {
90
+ i += 2;
91
+ continue;
92
+ }
93
+ if (char === "'") {
94
+ if (next === "'") {
95
+ i += 2;
96
+ continue;
97
+ }
98
+ inSingleQuote = false;
99
+ }
100
+ i++;
101
+ continue;
102
+ }
103
+
104
+ if (inDoubleQuote) {
105
+ if (char === '"') {
106
+ if (next === '"') {
107
+ i += 2;
108
+ continue;
109
+ }
110
+ inDoubleQuote = false;
111
+ }
112
+ i++;
113
+ continue;
114
+ }
115
+
116
+ if (dollarTag !== null) {
117
+ if (char === '$' && rawDdl.startsWith(dollarTag, i)) {
118
+ i += dollarTag.length;
119
+ dollarTag = null;
120
+ continue;
121
+ }
122
+ i++;
123
+ continue;
124
+ }
125
+
126
+ if (char === '/' && next === '*') {
127
+ inBlockComment = true;
128
+ i += 2;
129
+ continue;
130
+ }
131
+
132
+ if (char === "'") {
133
+ inSingleQuote = true;
134
+ i++;
135
+ continue;
136
+ }
137
+
138
+ if (char === '"') {
139
+ inDoubleQuote = true;
140
+ i++;
141
+ continue;
142
+ }
143
+
144
+ if (char === '$') {
145
+ const match = rawDdl.slice(i).match(/^\$[A-Za-z0-9_]*\$/);
146
+ if (match) {
147
+ dollarTag = match[0];
148
+ i += dollarTag.length;
149
+ continue;
150
+ }
151
+ }
152
+
153
+ // Check for -- down: marker at beginning of line outside strings/comments
154
+ const isLineStart = i === 0 || rawDdl[i - 1] === '\n';
155
+ if (isLineStart) {
156
+ const lineSlice = rawDdl.slice(i);
157
+ const match = lineSlice.match(/^[ \t]*--\s*down:?[ \t]*(?:\r?\n|$)/i);
158
+ if (match) {
159
+ splitIndex = i;
160
+ matchLength = match[0].length;
161
+ break;
162
+ }
163
+ }
164
+
165
+ i++;
166
+ }
167
+
168
+ if (splitIndex === -1) {
69
169
  return { upDdl: rawDdl.trim(), downDdl: null };
70
170
  }
71
- const splitIndex = match.index;
171
+
72
172
  const upDdl = rawDdl.slice(0, splitIndex).trim();
73
- const downDdl = rawDdl.slice(splitIndex + match[0].length).trim();
173
+ const downDdl = rawDdl.slice(splitIndex + matchLength).trim();
74
174
  return {
75
175
  upDdl,
76
176
  downDdl: downDdl.length > 0 ? downDdl : null
@@ -91,6 +191,7 @@ export function splitStatements(ddl: string): string[] {
91
191
  let inLineComment = false;
92
192
  let inBlockComment = false;
93
193
  let dollarTag: string | null = null;
194
+ let beginDepth = 0;
94
195
 
95
196
  let i = 0;
96
197
  while (i < ddl.length) {
@@ -207,8 +308,31 @@ export function splitStatements(ddl: string): string[] {
207
308
  }
208
309
  }
209
310
 
210
- // Statement terminator outside strings or comments
211
- if (char === ';') {
311
+ // Track BEGIN ... END blocks (e.g. triggers, compound statements)
312
+ const remaining = ddl.slice(i);
313
+ const prevChar = i > 0 ? ddl[i - 1] : ' ';
314
+ const isWordStart = /[\s,;(]/.test(prevChar);
315
+
316
+ if (isWordStart) {
317
+ const beginMatch = remaining.match(/^BEGIN\b/i);
318
+ if (beginMatch) {
319
+ beginDepth++;
320
+ current += beginMatch[0];
321
+ i += beginMatch[0].length;
322
+ continue;
323
+ }
324
+
325
+ const endMatch = remaining.match(/^END\b/i);
326
+ if (endMatch) {
327
+ if (beginDepth > 0) beginDepth--;
328
+ current += endMatch[0];
329
+ i += endMatch[0].length;
330
+ continue;
331
+ }
332
+ }
333
+
334
+ // Statement terminator outside strings or comments, but only when not inside a BEGIN...END block
335
+ if (char === ';' && beginDepth === 0) {
212
336
  const trimmed = current.trim();
213
337
  if (trimmed.length > 0) {
214
338
  statements.push(trimmed);
@@ -394,14 +518,15 @@ export async function runSliceMigrations(
394
518
  status: 'APPLIED',
395
519
  statementsApplied: pending.length
396
520
  });
397
- } catch (err: any) {
521
+ } catch (err: unknown) {
398
522
  hasFailure = true;
523
+ const errMessage = err instanceof Error ? err.message : String(err);
399
524
  migrations.push({
400
525
  slice: sliceName,
401
526
  filePath: path.relative(baseDir, filePath),
402
527
  status: 'FAILED',
403
528
  statementsApplied: 0,
404
- error: err.message
529
+ error: errMessage
405
530
  });
406
531
  }
407
532
  }
@@ -71,6 +71,9 @@ export const sliceSchema = \`
71
71
  ${sqlCols},
72
72
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
73
73
  );
74
+
75
+ -- down:
76
+ DROP TABLE IF EXISTS ${tableName};
74
77
  \`;
75
78
 
76
79
  // ============================================================================
@@ -222,6 +225,9 @@ export const sliceSchema = \`
222
225
  email TEXT NOT NULL UNIQUE,
223
226
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
224
227
  );
228
+
229
+ -- down:
230
+ DROP TABLE IF EXISTS ${tableName};
225
231
  \`;
226
232
 
227
233
  // ============================================================================
@@ -513,6 +519,62 @@ export function scaffoldCrud(
513
519
  return Ok(created);
514
520
  }
515
521
 
522
+ /**
523
+ * Scaffolds a safe shared domain module in `src/shared/<name>.ts`.
524
+ */
525
+ export function scaffoldShared(name: string, baseDir: string = process.cwd()): Result<string, ScaffoldError> {
526
+ const cleanName = name.replace(/\.ts$/, '');
527
+ const sharedDir = path.resolve(baseDir, 'src', 'shared');
528
+ if (!fs.existsSync(sharedDir)) {
529
+ fs.mkdirSync(sharedDir, { recursive: true });
530
+ }
531
+
532
+ const targetFile = path.join(sharedDir, `${cleanName}.ts`);
533
+ if (fs.existsSync(targetFile)) {
534
+ return Err({
535
+ code: 'SLICE_EXISTS',
536
+ message: `O módulo compartilhado "${cleanName}.ts" já existe em ${sharedDir}.`,
537
+ candidates: [targetFile]
538
+ });
539
+ }
540
+
541
+ const pascalName = toPascalCase(cleanName);
542
+ const content = `import type { DatabaseClient, Result } from 'synapsejs';
543
+ import { Ok, Err } from 'synapsejs';
544
+
545
+ /**
546
+ * Módulo compartilhado para o domínio ${cleanName}.
547
+ * Fatias não importam fatias — regras que envolvem múltiplas fatias ou transações cruzadas
548
+ * residem em src/shared/, recebem o DatabaseClient por parâmetro e abrem db.transaction().
549
+ */
550
+ export async function executeShared${pascalName}(
551
+ db: DatabaseClient,
552
+ params: Record<string, unknown> = {}
553
+ ): Promise<Result<{ success: boolean; data?: unknown }, string>> {
554
+ if (!db) {
555
+ return Err('NO_DATABASE');
556
+ }
557
+
558
+ return await db.transaction(async (tx) => {
559
+ // Implemente a lógica transacional entre fatias aqui usando tx
560
+ return Ok({ success: true, data: params });
561
+ });
562
+ }
563
+ `;
564
+
565
+ try {
566
+ fs.writeFileSync(targetFile, content, 'utf-8');
567
+ return Ok(targetFile);
568
+ } catch (err: unknown) {
569
+ const message = err instanceof Error ? err.message : String(err);
570
+ return Err({
571
+ code: 'WRITE_FAILED',
572
+ message: `Falha ao escrever módulo compartilhado: ${message}`,
573
+ candidates: [targetFile]
574
+ });
575
+ }
576
+ }
577
+
516
578
  if (import.meta.main) {
517
579
  const domain = process.argv[2] || 'core';
518
580
  const name = process.argv[3] || 'example-feature';
@@ -25,26 +25,30 @@ export function parseTableDependencies(ddl: string): { created: string[]; refere
25
25
  const created: string[] = [];
26
26
  const referenced: string[] = [];
27
27
 
28
- // Match CREATE TABLE [IF NOT EXISTS] <name>
29
- const createRegex = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?([a-zA-Z0-9_]+)["'`]?/gi;
30
- let match: RegExpExecArray | null = createRegex.exec(ddl);
28
+ // Strip SQL comments to avoid false-positive dependencies
29
+ const cleanDdl = ddl.replace(/--.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
30
+
31
+ // Match CREATE TABLE [IF NOT EXISTS] [schema.]<name>
32
+ const createRegex =
33
+ /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:["'`]?[a-zA-Z0-9_]+["'`]?\.)?["'`]?([a-zA-Z0-9_]+)["'`]?/gi;
34
+ let match: RegExpExecArray | null = createRegex.exec(cleanDdl);
31
35
  while (match !== null) {
32
36
  const tableName = match[1].toLowerCase();
33
37
  if (!created.includes(tableName)) {
34
38
  created.push(tableName);
35
39
  }
36
- match = createRegex.exec(ddl);
40
+ match = createRegex.exec(cleanDdl);
37
41
  }
38
42
 
39
- // Match REFERENCES <name>
40
- const refRegex = /REFERENCES\s+["'`]?([a-zA-Z0-9_]+)["'`]?/gi;
41
- let refMatch: RegExpExecArray | null = refRegex.exec(ddl);
43
+ // Match REFERENCES [schema.]<name>[(columns)]
44
+ const refRegex = /REFERENCES\s+(?:["'`]?[a-zA-Z0-9_]+["'`]?\.)?["'`]?([a-zA-Z0-9_]+)["'`]?/gi;
45
+ let refMatch: RegExpExecArray | null = refRegex.exec(cleanDdl);
42
46
  while (refMatch !== null) {
43
47
  const tableName = refMatch[1].toLowerCase();
44
48
  if (!created.includes(tableName) && !referenced.includes(tableName)) {
45
49
  referenced.push(tableName);
46
50
  }
47
- refMatch = refRegex.exec(ddl);
51
+ refMatch = refRegex.exec(cleanDdl);
48
52
  }
49
53
 
50
54
  return { created, referenced };
@@ -92,9 +96,12 @@ export function orderSlicesByDag(sliceFiles: string[]): string[] {
92
96
  for (const refTable of meta.tablesReferenced) {
93
97
  const parentFilePath = tableToSlice.get(refTable);
94
98
  if (parentFilePath && parentFilePath !== filePath) {
95
- // parentFilePath must run before filePath
96
- graph.get(parentFilePath)?.push(filePath);
97
- inDegree.set(filePath, (inDegree.get(filePath) || 0) + 1);
99
+ // parentFilePath must run before filePath (prevent duplicate edge)
100
+ const neighbors = graph.get(parentFilePath);
101
+ if (neighbors && !neighbors.includes(filePath)) {
102
+ neighbors.push(filePath);
103
+ inDegree.set(filePath, (inDegree.get(filePath) || 0) + 1);
104
+ }
98
105
  }
99
106
  }
100
107
  }
@@ -75,11 +75,31 @@ const SERVER_ONLY_PATTERNS: Array<{ label: string; pattern: RegExp }> = [
75
75
  { label: 'UPDATE statement', pattern: /\bUPDATE\s+\w+\s+SET\b/i },
76
76
  { label: 'DELETE statement', pattern: /\bDELETE\s+FROM\b/i },
77
77
  { label: 'CREATE TABLE statement', pattern: /\bCREATE\s+TABLE\b/i },
78
+ { label: 'ALTER TABLE statement', pattern: /\bALTER\s+TABLE\b/i },
79
+ { label: 'DROP TABLE statement', pattern: /\bDROP\s+TABLE\b/i },
78
80
  { label: 'database call', pattern: /\bdb\s*\.\s*(?:query|findMany|findOne|insert|update|delete|sql|sqlOne)\b/ },
79
81
  { label: 'sliceSchema symbol', pattern: /\bsliceSchema\b/ },
80
82
  { label: 'test runner import', pattern: /from\s+['"]bun:test['"]/ },
81
- { label: 'process.env access', pattern: /\bprocess\.env\b/ },
82
- { label: 'Bun global', pattern: /\bBun\./ }
83
+ { label: 'process.env access', pattern: /\bprocess\s*(?:\.\s*env|\[\s*['"]env['"]\s*\])/ },
84
+ { label: 'process destructuring', pattern: /\b(?:const|let|var)\s*\{[^}]*\benv\b[^}]*\}\s*=\s*process\b/ },
85
+ { label: 'Bun global', pattern: /\bBun(?:\.|\s*\[)/ },
86
+ {
87
+ label: 'globalThis server access',
88
+ pattern: /\bglobalThis\s*(?:\.\s*(?:process|Bun)|\[\s*['"](?:process|Bun)['"]\s*\])/
89
+ },
90
+ {
91
+ label: 'node server module import',
92
+ pattern: /from\s+['"](?:node:)?(?:fs|child_process|cluster|dgram|dns|net|tls|v8|vm)['"]/
93
+ },
94
+ {
95
+ label: 'server dynamic import',
96
+ pattern: /\bimport\s*\(\s*['"](?:node:)?(?:fs|child_process|sqlite|postgres|bun|cluster|net|tls)['"]/
97
+ },
98
+ {
99
+ label: 'server require leak',
100
+ pattern: /\brequire\s*\(\s*['"](?:node:)?(?:fs|child_process|sqlite|postgres|bun|cluster|net|tls)['"]/
101
+ },
102
+ { label: 'dynamic code evaluation', pattern: /\b(?:eval|new\s+Function)\s*\(/ }
83
103
  ];
84
104
 
85
105
  interface DeclEntry {
@@ -595,7 +615,8 @@ export function splitSlice(sliceFilePath: string, baseDir: string = process.cwd(
595
615
  message:
596
616
  `Uma fatia não importa outra: ${path.basename(sliceFilePath)} alcança ` +
597
617
  `${path.relative(process.cwd(), illegalImport)}. Mova o código comum para ` +
598
- 'src/shared/<nome>.ts, receba o db por parâmetro e chame de dentro das duas fatias.',
618
+ 'src/shared/<nome>.ts, receba o db por parâmetro e chame de dentro das duas fatias. ' +
619
+ 'Dica: use `synapse new-shared <nome>` para criar um módulo compartilhado seguro em src/shared/.',
599
620
  candidates: [illegalImport]
600
621
  });
601
622
  }
@@ -823,8 +844,12 @@ export function verifySplit(result: SplitResult, outDir: string): SplitVerificat
823
844
  leaks.push('pacote-raiz-no-cliente');
824
845
  }
825
846
 
847
+ const normalizedCode = clientArtifact.code.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) =>
848
+ String.fromCharCode(parseInt(hex, 16))
849
+ );
850
+
826
851
  for (const { label, pattern } of SERVER_ONLY_PATTERNS) {
827
- if (pattern.test(clientArtifact.code)) {
852
+ if (pattern.test(clientArtifact.code) || pattern.test(normalizedCode)) {
828
853
  leaks.push(label);
829
854
  }
830
855
  }
@@ -820,6 +820,9 @@ export const sliceSchema = \`
820
820
  roles TEXT NOT NULL DEFAULT 'user',
821
821
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
822
822
  );
823
+
824
+ -- down:
825
+ DROP TABLE IF EXISTS users;
823
826
  \`;
824
827
 
825
828
  // Para criar o primeiro usuário, num seed fora da fatia:
@@ -1099,6 +1102,10 @@ export const sliceSchema = \`
1099
1102
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
1100
1103
  UNIQUE(provider, provider_user_id)
1101
1104
  );
1105
+
1106
+ -- down:
1107
+ DROP TABLE IF EXISTS oauth_accounts;
1108
+ DROP TABLE IF EXISTS users;
1102
1109
  \`;
1103
1110
 
1104
1111
  // ============================================================================
@@ -1366,6 +1373,9 @@ export const sliceSchema = \`
1366
1373
  enabled INTEGER DEFAULT 1,
1367
1374
  verified_at TIMESTAMP
1368
1375
  );
1376
+
1377
+ -- down:
1378
+ DROP TABLE IF EXISTS ${names.table};
1369
1379
  \`;
1370
1380
 
1371
1381
  // ============================================================================
@@ -9,6 +9,7 @@
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import { Err, Ok, type Result } from '../core/machine-types';
12
+ import { SYNAPSE_VERSION } from '../version';
12
13
  import { type BuildReportEntry, buildAllClientBundles } from './client-bundler';
13
14
  import { resolveSlicesDir, type SlicesDirError } from './slice-discovery';
14
15
 
@@ -60,10 +61,23 @@ export async function buildStandalone(
60
61
  fs.mkdirSync(path.dirname(slicesDest), { recursive: true });
61
62
  fs.cpSync(slicesSrc, slicesDest, { recursive: true });
62
63
 
63
- // 5. Copy package.json & tsconfig.json if present
64
+ // 5. Copy package.json & tsconfig.json if present (normalizing workspace:* dependencies)
64
65
  const pkgSrc = path.join(appRoot, 'package.json');
65
66
  if (fs.existsSync(pkgSrc)) {
66
- fs.copyFileSync(pkgSrc, path.join(standaloneDir, 'package.json'));
67
+ try {
68
+ const rawPkg = fs.readFileSync(pkgSrc, 'utf-8');
69
+ const pkg = JSON.parse(rawPkg);
70
+ if (pkg.dependencies) {
71
+ for (const [dep, ver] of Object.entries(pkg.dependencies)) {
72
+ if (typeof ver === 'string' && ver.startsWith('workspace:')) {
73
+ pkg.dependencies[dep] = `^${SYNAPSE_VERSION}`;
74
+ }
75
+ }
76
+ }
77
+ fs.writeFileSync(path.join(standaloneDir, 'package.json'), `${JSON.stringify(pkg, null, 2)}\n`, 'utf-8');
78
+ } catch {
79
+ fs.copyFileSync(pkgSrc, path.join(standaloneDir, 'package.json'));
80
+ }
67
81
  }
68
82
 
69
83
  const tsconfigSrc = path.join(appRoot, 'tsconfig.json');
@@ -31,6 +31,43 @@ export type EnqueueFn = <TPayload>(
31
31
 
32
32
  export type BroadcastFn = (topic: string, data: unknown) => number;
33
33
 
34
+ export interface CookieOptions {
35
+ httpOnly?: boolean;
36
+ secure?: boolean;
37
+ sameSite?: 'lax' | 'strict' | 'none';
38
+ maxAge?: number;
39
+ path?: string;
40
+ domain?: string;
41
+ }
42
+
43
+ export interface PendingCookie {
44
+ name: string;
45
+ value: string;
46
+ options?: CookieOptions;
47
+ }
48
+
49
+ export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
50
+ const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
51
+ if (options.maxAge !== undefined) {
52
+ parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
53
+ }
54
+ if (options.domain && !/[\r\n;\s]/.test(options.domain)) {
55
+ parts.push(`Domain=${options.domain.trim()}`);
56
+ }
57
+ const cleanPath = options.path && !/[\r\n;\s]/.test(options.path) ? options.path.trim() : '/';
58
+ parts.push(`Path=${cleanPath}`);
59
+ if (options.secure) {
60
+ parts.push('Secure');
61
+ }
62
+ if (options.httpOnly) {
63
+ parts.push('HttpOnly');
64
+ }
65
+ if (options.sameSite) {
66
+ parts.push(`SameSite=${options.sameSite.charAt(0).toUpperCase() + options.sameSite.slice(1).toLowerCase()}`);
67
+ }
68
+ return parts.join('; ');
69
+ }
70
+
34
71
  export interface ActionContext<TServices = Record<string, unknown>> extends DatabaseClient {
35
72
  /** Underlying Database Client */
36
73
  readonly db: DatabaseClient;
@@ -56,6 +93,15 @@ export interface ActionContext<TServices = Record<string, unknown>> extends Data
56
93
  /** Broadcasts a real-time event to SSE subscribers */
57
94
  readonly broadcast: BroadcastFn;
58
95
 
96
+ /** Invalidates SSR cache entries by tag or completely */
97
+ readonly invalidateCache: (tags?: string[]) => void;
98
+
99
+ /** Sets an HTTP response cookie (e.g. HttpOnly session token) */
100
+ readonly setCookie: (name: string, value: string, options?: CookieOptions) => void;
101
+
102
+ /** Internal list of pending cookies to be set on the response */
103
+ readonly _pendingCookies?: PendingCookie[];
104
+
59
105
  /** Lazy adapter for Kysely (if configured or requested) */
60
106
  readonly kysely?: unknown;
61
107
  }
@@ -83,6 +129,7 @@ export interface ActionContextOptions<TServices = Record<string, unknown>> {
83
129
  enqueue?: EnqueueFn;
84
130
  storage?: StorageClient;
85
131
  broadcast?: BroadcastFn;
132
+ invalidateCache?: (tags?: string[]) => void;
86
133
  kysely?: unknown;
87
134
  }
88
135
 
@@ -102,9 +149,15 @@ export function createActionContext<TServices = Record<string, unknown>>(
102
149
  enqueue = async () => 'job_noop',
103
150
  storage = getStorage(),
104
151
  broadcast = (topic: string, data: unknown) => getEventHub().publish(topic, data),
152
+ invalidateCache = () => {},
105
153
  kysely
106
154
  } = options;
107
155
 
156
+ const pendingCookies: PendingCookie[] = [];
157
+ const setCookie = (name: string, value: string, options?: CookieOptions) => {
158
+ pendingCookies.push({ name, value, options });
159
+ };
160
+
108
161
  const context: ActionContext<TServices> = {
109
162
  // ActionContext properties
110
163
  db,
@@ -115,6 +168,9 @@ export function createActionContext<TServices = Record<string, unknown>>(
115
168
  enqueue,
116
169
  storage,
117
170
  broadcast,
171
+ invalidateCache,
172
+ setCookie,
173
+ _pendingCookies: pendingCookies,
118
174
  kysely,
119
175
 
120
176
  // DatabaseClient proxy methods
@@ -0,0 +1,89 @@
1
+ /**
2
+ * SynapseJS - Declarative Action Definition Primitive
3
+ *
4
+ * Implements "Secure by Default" (Fail-Closed) semantics and wire-speed automatic
5
+ * TypeBox schema validation. Prevents inadvertent public exposure of RPC endpoints
6
+ * and eliminates repetitive manual `Value.Check` and `requireAuth` boilerplate.
7
+ */
8
+
9
+ import type { Static, TSchema } from '@sinclair/typebox';
10
+ import { Value } from '@sinclair/typebox/value';
11
+ import type { DatabaseClient } from './database-client';
12
+ import { Err, type Result } from './machine-types';
13
+ import { requireAuth, type SessionContext } from './session-context';
14
+
15
+ export type ActionAuthRule = 'public' | string[];
16
+
17
+ export interface ActionDefinitionConfig<TSchemaInput extends TSchema | undefined, TOutput> {
18
+ /**
19
+ * Optional TypeBox schema for input payload validation.
20
+ * If supplied, incoming requests are automatically validated before the handler runs.
21
+ */
22
+ input?: TSchemaInput;
23
+
24
+ /**
25
+ * Authorization rules for this action.
26
+ * - Omitted / undefined: Requires an authenticated user session (Fail-Closed by default).
27
+ * - Array of strings (e.g. ['admin', 'manager']): Requires the session to possess at least one of these roles.
28
+ * - 'public': Explicitly marks the action as accessible to unauthenticated callers.
29
+ */
30
+ auth?: ActionAuthRule;
31
+
32
+ /**
33
+ * Handler function executing business logic.
34
+ * Receives strongly-typed validated input, database client and authenticated session context.
35
+ */
36
+ handler: (ctx: {
37
+ input: TSchemaInput extends TSchema ? Static<TSchemaInput> : unknown;
38
+ db?: DatabaseClient;
39
+ session?: SessionContext;
40
+ }) => Promise<TOutput> | TOutput;
41
+ }
42
+
43
+ export type ActionHandlerFn<TSchemaInput extends TSchema | undefined, TOutput> = {
44
+ (
45
+ payload: unknown,
46
+ db?: DatabaseClient,
47
+ session?: SessionContext
48
+ ): Promise<TOutput | Result<never, 'INVALID_SCHEMA' | 'UNAUTHORIZED' | 'FORBIDDEN'>>;
49
+ readonly __isSynapseAction: true;
50
+ readonly config: ActionDefinitionConfig<TSchemaInput, TOutput>;
51
+ };
52
+
53
+ export function defineAction<TSchemaInput extends TSchema | undefined, TOutput>(
54
+ config: ActionDefinitionConfig<TSchemaInput, TOutput>
55
+ ): ActionHandlerFn<TSchemaInput, TOutput> {
56
+ const actionFn = async (
57
+ payload: unknown,
58
+ db?: DatabaseClient,
59
+ session?: SessionContext
60
+ ): Promise<TOutput | Result<never, 'INVALID_SCHEMA' | 'UNAUTHORIZED' | 'FORBIDDEN'>> => {
61
+ // 1. Fail-Closed Authentication Guard
62
+ if (config.auth !== 'public') {
63
+ const requiredRoles = Array.isArray(config.auth) ? config.auth : undefined;
64
+ const auth = requireAuth(session, requiredRoles);
65
+ if (!auth.ok) {
66
+ return Err(auth.error);
67
+ }
68
+ }
69
+
70
+ // 2. Automatic Wire-Speed Schema Validation
71
+ if (config.input) {
72
+ if (!Value.Check(config.input, payload)) {
73
+ return Err('INVALID_SCHEMA');
74
+ }
75
+ }
76
+
77
+ // 3. Typed Handler Invocation
78
+ return config.handler({
79
+ input: payload as TSchemaInput extends TSchema ? Static<TSchemaInput> : unknown,
80
+ db,
81
+ session
82
+ });
83
+ };
84
+
85
+ Object.defineProperty(actionFn, '__isSynapseAction', { value: true, writable: false });
86
+ Object.defineProperty(actionFn, 'config', { value: config, writable: false });
87
+
88
+ return actionFn as ActionHandlerFn<TSchemaInput, TOutput>;
89
+ }