@ismaelsoilet/synapsejs 1.1.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/bin/synapse.ts +919 -0
  4. package/package.json +70 -0
  5. package/src/client/components.tsx +508 -0
  6. package/src/client/context.tsx +62 -0
  7. package/src/client/hooks.ts +152 -0
  8. package/src/client/i18n.ts +40 -0
  9. package/src/client.ts +66 -0
  10. package/src/compiler/agent-diagnostic-json.ts +113 -0
  11. package/src/compiler/ast-daemon-compressor.ts +190 -0
  12. package/src/compiler/client-bundler.ts +356 -0
  13. package/src/compiler/context-bench.ts +191 -0
  14. package/src/compiler/contract.ts +260 -0
  15. package/src/compiler/db-schema-generator.ts +306 -0
  16. package/src/compiler/fields-parser.ts +267 -0
  17. package/src/compiler/impact-analyzer.ts +279 -0
  18. package/src/compiler/migration-runner.ts +555 -0
  19. package/src/compiler/oracle-runner.ts +293 -0
  20. package/src/compiler/scaffolder.ts +528 -0
  21. package/src/compiler/schema-dag.ts +137 -0
  22. package/src/compiler/schema-drift.ts +184 -0
  23. package/src/compiler/slice-discovery.ts +154 -0
  24. package/src/compiler/slice-splitter.ts +838 -0
  25. package/src/compiler/slice-templates.ts +1527 -0
  26. package/src/compiler/standalone-builder.ts +117 -0
  27. package/src/core/action-context.ts +152 -0
  28. package/src/core/config.ts +77 -0
  29. package/src/core/database-client.ts +246 -0
  30. package/src/core/database-factory.ts +46 -0
  31. package/src/core/image-optimizer.ts +83 -0
  32. package/src/core/index.ts +14 -0
  33. package/src/core/jobs.ts +53 -0
  34. package/src/core/machine-types.ts +75 -0
  35. package/src/core/oracle.ts +20 -0
  36. package/src/core/postgres-client.ts +164 -0
  37. package/src/core/postgres-queue.ts +288 -0
  38. package/src/core/query-builder.ts +343 -0
  39. package/src/core/rpc-client.ts +70 -0
  40. package/src/core/session-context.ts +87 -0
  41. package/src/core/session-cookie.ts +81 -0
  42. package/src/core/session-token.ts +170 -0
  43. package/src/core/sqlite-client.ts +201 -0
  44. package/src/core/storage.ts +317 -0
  45. package/src/index.ts +133 -0
  46. package/src/mcp/server.ts +553 -0
  47. package/src/runtime/client-entry.ts +65 -0
  48. package/src/runtime/discovery-rules.ts +63 -0
  49. package/src/runtime/event-hub.ts +97 -0
  50. package/src/runtime/postgres-event-hub.ts +148 -0
  51. package/src/runtime/queue-engine.ts +255 -0
  52. package/src/runtime/rate-limiter.ts +152 -0
  53. package/src/runtime/server.ts +1550 -0
  54. package/src/runtime/uploads.ts +192 -0
  55. package/templates/starter/.dockerignore +7 -0
  56. package/templates/starter/Dockerfile +40 -0
  57. package/templates/starter/README.md +40 -0
  58. package/templates/starter/gitignore +6 -0
  59. package/templates/starter/package.json +34 -0
  60. package/templates/starter/src/slices/welcome/hello-world.slice.tsx +172 -0
  61. package/templates/starter/tsconfig.json +21 -0
@@ -0,0 +1,838 @@
1
+ /**
2
+ * SynapseJS - Isomorphic Slice Splitter
3
+ *
4
+ * Partitions one contiguous `.slice.tsx` (N = 1) into three verifiable modules:
5
+ *
6
+ * <slice>/shared.tsx type contracts (erased at runtime, safe for both sides)
7
+ * <slice>/server.ts database access, schema DDL, server actions
8
+ * <slice>/client.tsx React UI plus transparent RPC stubs
9
+ *
10
+ * The partition is computed by reachability over the module's own dependency
11
+ * graph, resolved through the TypeScript type checker. Imports are passed
12
+ * through from the source — never synthesized — so an emitted module resolves
13
+ * exactly where the original did.
14
+ *
15
+ * Two gates make the isolation claim verifiable instead of declarative:
16
+ * 1. the emitted modules must typecheck;
17
+ * 2. the client module must not reference any server-only symbol.
18
+ */
19
+
20
+ import * as fs from 'fs';
21
+ import * as path from 'path';
22
+ import * as ts from 'typescript';
23
+ import { Err, Ok, type Result } from '../core/machine-types';
24
+ import {
25
+ COMPONENT_SUFFIXES,
26
+ SERVER_ROOT_SUFFIXES,
27
+ SLICE_DDL_EXPORT,
28
+ SLICE_META_EXPORT,
29
+ TEST_ONLY_EXPORTS
30
+ } from '../runtime/discovery-rules';
31
+
32
+ export type ArtifactKind = 'shared' | 'server' | 'client';
33
+
34
+ export interface SplitArtifact {
35
+ kind: ArtifactKind;
36
+ fileName: string;
37
+ code: string;
38
+ }
39
+
40
+ export interface SplitResult {
41
+ sliceName: string;
42
+ artifacts: SplitArtifact[];
43
+ }
44
+
45
+ export type SplitErrorCode = 'SLICE_NOT_FOUND' | 'SLICE_IMPORTS_SLICE' | 'NO_ROOTS' | 'UNPARSED';
46
+
47
+ export interface SplitError {
48
+ code: SplitErrorCode;
49
+ message: string;
50
+ candidates: string[];
51
+ }
52
+
53
+ export interface SplitDiagnostic {
54
+ file: string;
55
+ line: number;
56
+ column: number;
57
+ code: string;
58
+ message: string;
59
+ }
60
+
61
+ export interface SplitVerification {
62
+ status: 'PASS' | 'FAIL';
63
+ diagnostics: SplitDiagnostic[];
64
+ leaks: string[];
65
+ }
66
+
67
+ const CLIENT_ROOTS = [...COMPONENT_SUFFIXES];
68
+ const TEST_ONLY_NAMES: readonly string[] = [...TEST_ONLY_EXPORTS];
69
+
70
+ const SERVER_ONLY_PATTERNS: Array<{ label: string; pattern: RegExp }> = [
71
+ // `SELECT ... FROM` e não `SELECT` seguido de qualquer palavra: sem o FROM, a
72
+ // tag HTML `<select id="x">` de um formulário casava como vazamento de SQL.
73
+ { label: 'SELECT statement', pattern: /\bSELECT\s+[\w*"`][\w*"`.,\s]*\bFROM\b/i },
74
+ { label: 'INSERT statement', pattern: /\bINSERT\s+INTO\b/i },
75
+ { label: 'UPDATE statement', pattern: /\bUPDATE\s+\w+\s+SET\b/i },
76
+ { label: 'DELETE statement', pattern: /\bDELETE\s+FROM\b/i },
77
+ { label: 'CREATE TABLE statement', pattern: /\bCREATE\s+TABLE\b/i },
78
+ { label: 'database call', pattern: /\bdb\s*\.\s*(?:query|findMany|findOne|insert|update|delete|sql|sqlOne)\b/ },
79
+ { label: 'sliceSchema symbol', pattern: /\bsliceSchema\b/ },
80
+ { 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
+ ];
84
+
85
+ interface DeclEntry {
86
+ key: string;
87
+ node: ts.Node;
88
+ statement: ts.Node;
89
+ isExported: boolean;
90
+ isTypeOnly: boolean;
91
+ position: number;
92
+ dependencies: Set<string>;
93
+ imports: Set<string>;
94
+ }
95
+
96
+ function compilerOptions(): ts.CompilerOptions {
97
+ return {
98
+ target: ts.ScriptTarget.ESNext,
99
+ module: ts.ModuleKind.ESNext,
100
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
101
+ jsx: ts.JsxEmit.ReactJSX,
102
+ strict: true,
103
+ noEmit: true,
104
+ skipLibCheck: true,
105
+ allowImportingTsExtensions: true,
106
+ esModuleInterop: true,
107
+ allowSyntheticDefaultImports: true,
108
+ types: []
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Uses the project's own tsconfig so the emitted modules are checked under the
114
+ * same options the slice is authored with. A gate that relaxes strictness would
115
+ * report PASS on code the project itself rejects.
116
+ */
117
+ function projectOptions(startDir: string): ts.CompilerOptions {
118
+ const configPath = ts.findConfigFile(startDir, ts.sys.fileExists, 'tsconfig.json');
119
+ if (!configPath) {
120
+ return compilerOptions();
121
+ }
122
+
123
+ const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
124
+ if (configFile.error) {
125
+ return compilerOptions();
126
+ }
127
+
128
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath));
129
+
130
+ return {
131
+ ...compilerOptions(),
132
+ ...parsed.options,
133
+ noEmit: true,
134
+ declaration: false,
135
+ composite: false,
136
+ incremental: false
137
+ };
138
+ }
139
+
140
+ function hasExportModifier(node: ts.Node, _sourceFile: ts.SourceFile): boolean {
141
+ const statement = ts.isVariableDeclaration(node) ? node.parent.parent : node;
142
+ const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
143
+ return Boolean(modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword));
144
+ }
145
+
146
+ function declarationKey(node: ts.Node): string | null {
147
+ if (
148
+ ts.isFunctionDeclaration(node) ||
149
+ ts.isClassDeclaration(node) ||
150
+ ts.isTypeAliasDeclaration(node) ||
151
+ ts.isInterfaceDeclaration(node) ||
152
+ ts.isEnumDeclaration(node)
153
+ ) {
154
+ return node.name?.text ?? null;
155
+ }
156
+
157
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
158
+ return node.name.text;
159
+ }
160
+
161
+ return null;
162
+ }
163
+
164
+ function owningTopLevelKey(node: ts.Node, sourceFile: ts.SourceFile): string | null {
165
+ let current: ts.Node = node;
166
+
167
+ while (current.parent && current.parent !== sourceFile) {
168
+ current = current.parent;
169
+ }
170
+
171
+ if (ts.isVariableStatement(current)) {
172
+ let cursor: ts.Node = node;
173
+ while (cursor !== current) {
174
+ const key = declarationKey(cursor);
175
+ if (key) {
176
+ return key;
177
+ }
178
+ if (!cursor.parent) {
179
+ return null;
180
+ }
181
+ cursor = cursor.parent;
182
+ }
183
+ return null;
184
+ }
185
+
186
+ return declarationKey(current);
187
+ }
188
+
189
+ function isPropertyName(node: ts.Identifier): boolean {
190
+ const parent = node.parent;
191
+ if (!parent) {
192
+ return false;
193
+ }
194
+ if (ts.isPropertyAccessExpression(parent) && parent.name === node) {
195
+ return true;
196
+ }
197
+ if (ts.isPropertyAssignment(parent) && parent.name === node) {
198
+ return true;
199
+ }
200
+ if (ts.isPropertySignature(parent) && parent.name === node) {
201
+ return true;
202
+ }
203
+ if (ts.isBindingElement(parent) && parent.propertyName === node) {
204
+ return true;
205
+ }
206
+ return false;
207
+ }
208
+
209
+ function isTopLevelDeclarationOf(node: ts.Node, sourceFile: ts.SourceFile): boolean {
210
+ let current: ts.Node = node;
211
+ while (current.parent && current.parent !== sourceFile) {
212
+ current = current.parent;
213
+ }
214
+ return current.parent === sourceFile;
215
+ }
216
+
217
+ function collectDeclarations(sourceFile: ts.SourceFile, checker: ts.TypeChecker): Map<string, DeclEntry> {
218
+ const entries = new Map<string, DeclEntry>();
219
+
220
+ for (const statement of sourceFile.statements) {
221
+ if (ts.isVariableStatement(statement)) {
222
+ for (const declaration of statement.declarationList.declarations) {
223
+ const key = declarationKey(declaration);
224
+ if (!key || !ts.isIdentifier(declaration.name)) {
225
+ continue;
226
+ }
227
+ entries.set(key, {
228
+ key,
229
+ node: declaration,
230
+ statement,
231
+ isExported: hasExportModifier(declaration, sourceFile),
232
+ isTypeOnly: false,
233
+ position: statement.getStart(sourceFile),
234
+ dependencies: new Set(),
235
+ imports: new Set()
236
+ });
237
+ }
238
+ continue;
239
+ }
240
+
241
+ const key = declarationKey(statement);
242
+ if (!key) {
243
+ continue;
244
+ }
245
+
246
+ entries.set(key, {
247
+ key,
248
+ node: statement,
249
+ statement,
250
+ isExported: hasExportModifier(statement, sourceFile),
251
+ isTypeOnly: ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement),
252
+ position: statement.getStart(sourceFile),
253
+ dependencies: new Set(),
254
+ imports: new Set()
255
+ });
256
+ }
257
+
258
+ for (const entry of entries.values()) {
259
+ collectReferences(entry.node, entry, sourceFile, checker, entry);
260
+ }
261
+
262
+ return entries;
263
+ }
264
+
265
+ function collectReferences(
266
+ node: ts.Node,
267
+ entry: DeclEntry,
268
+ sourceFile: ts.SourceFile,
269
+ checker: ts.TypeChecker,
270
+ target: { dependencies: Set<string>; imports: Set<string> }
271
+ ): void {
272
+ const visit = (current: ts.Node): void => {
273
+ if (ts.isIdentifier(current) && !isPropertyName(current)) {
274
+ const symbol = checker.getSymbolAtLocation(current);
275
+
276
+ if (symbol && symbol.flags & ts.SymbolFlags.Alias) {
277
+ // Imported binding: remember it by its local name for passthrough.
278
+ target.imports.add(symbol.getName());
279
+ } else if (symbol) {
280
+ const declaration = symbol.declarations?.find((d) => d.getSourceFile() === sourceFile);
281
+
282
+ if (declaration && isTopLevelDeclarationOf(declaration, sourceFile)) {
283
+ const dependencyKey = owningTopLevelKey(declaration, sourceFile);
284
+ if (dependencyKey && dependencyKey !== entry.key) {
285
+ target.dependencies.add(dependencyKey);
286
+ }
287
+ }
288
+ }
289
+ }
290
+ ts.forEachChild(current, visit);
291
+ };
292
+
293
+ visit(node);
294
+ }
295
+
296
+ /**
297
+ * A server root that becomes an RPC stub contributes only its wire signature to
298
+ * the client: the payload parameter and the return type. The remaining
299
+ * parameters (database, session) and the body stay on the server.
300
+ */
301
+ function signatureReferences(
302
+ entry: DeclEntry,
303
+ sourceFile: ts.SourceFile,
304
+ checker: ts.TypeChecker
305
+ ): { dependencies: Set<string>; imports: Set<string> } {
306
+ const collected = { dependencies: new Set<string>(), imports: new Set<string>() };
307
+
308
+ if (ts.isFunctionDeclaration(entry.node)) {
309
+ const payload = entry.node.parameters[0];
310
+ if (payload) {
311
+ collectReferences(payload, entry, sourceFile, checker, collected);
312
+ }
313
+ if (entry.node.type) {
314
+ collectReferences(entry.node.type, entry, sourceFile, checker, collected);
315
+ }
316
+ } else {
317
+ collectReferences(entry.node, entry, sourceFile, checker, collected);
318
+ }
319
+
320
+ return collected;
321
+ }
322
+
323
+ function reachableFrom(
324
+ roots: string[],
325
+ entries: Map<string, DeclEntry>,
326
+ overrideDependencies?: Map<string, Set<string>>
327
+ ): Set<string> {
328
+ const visited = new Set<string>();
329
+ const queue = [...roots];
330
+
331
+ while (queue.length > 0) {
332
+ const key = queue.pop() as string;
333
+ if (visited.has(key)) {
334
+ continue;
335
+ }
336
+ visited.add(key);
337
+
338
+ const entry = entries.get(key);
339
+ if (!entry) {
340
+ continue;
341
+ }
342
+
343
+ const dependencies = overrideDependencies?.get(key) ?? entry.dependencies;
344
+ for (const dependency of dependencies) {
345
+ if (!visited.has(dependency)) {
346
+ queue.push(dependency);
347
+ }
348
+ }
349
+ }
350
+
351
+ return visited;
352
+ }
353
+
354
+ function rootsFor(entries: Map<string, DeclEntry>, suffixes: readonly string[], names: readonly string[]): string[] {
355
+ const roots: string[] = [];
356
+ for (const entry of entries.values()) {
357
+ if (!entry.isExported || TEST_ONLY_NAMES.includes(entry.key)) {
358
+ continue;
359
+ }
360
+ if (names.includes(entry.key) || suffixes.some((suffix) => entry.key.endsWith(suffix))) {
361
+ roots.push(entry.key);
362
+ }
363
+ }
364
+ return roots;
365
+ }
366
+
367
+ /**
368
+ * Relative specifiers point at files sitting next to the original slice, so they
369
+ * must be re-anchored to the artifact directory. Package specifiers pass through
370
+ * untouched.
371
+ */
372
+ function rewriteModuleSpecifier(specifier: string, sourceDir: string, outDir: string): string {
373
+ if (!specifier.startsWith('.')) {
374
+ return specifier;
375
+ }
376
+
377
+ const absolute = path.resolve(sourceDir, specifier);
378
+ const relative = path.relative(outDir, absolute).split(path.sep).join('/');
379
+ return relative.startsWith('.') ? relative : `./${relative}`;
380
+ }
381
+
382
+ function emitImportDeclaration(
383
+ declaration: ts.ImportDeclaration,
384
+ usedNames: Set<string>,
385
+ sourceDir: string,
386
+ outDir: string
387
+ ): string | null {
388
+ const clause = declaration.importClause;
389
+ const source = declaration.moduleSpecifier as ts.StringLiteral;
390
+ const moduleSpecifier = `'${rewriteModuleSpecifier(source.text, sourceDir, outDir)}'`;
391
+
392
+ if (!clause) {
393
+ return `import ${moduleSpecifier};`;
394
+ }
395
+
396
+ const parts: string[] = [];
397
+ const defaultIsType = Boolean(clause.name) && !usedNames.has(clause.name?.text ?? '');
398
+ if (clause.name && usedNames.has(clause.name.text)) {
399
+ parts.push(clause.name.text);
400
+ }
401
+
402
+ const namedParts: string[] = [];
403
+ if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
404
+ if (usedNames.has(clause.namedBindings.name.text)) {
405
+ namedParts.push(`* as ${clause.namedBindings.name.text}`);
406
+ }
407
+ } else if (clause.namedBindings) {
408
+ for (const specifier of clause.namedBindings.elements) {
409
+ if (!usedNames.has(specifier.name.text)) {
410
+ continue;
411
+ }
412
+ const prefix = specifier.isTypeOnly ? 'type ' : '';
413
+ const imported = specifier.propertyName ? `${specifier.propertyName.text} as ` : '';
414
+ namedParts.push(`${prefix}${imported}${specifier.name.text}`);
415
+ }
416
+ }
417
+
418
+ // Se tudo que sobrou é tipo, o import inteiro vira `import type`: erra menos e deixa o
419
+ // gate do pacote distinguir valor de tipo sem adivinhar. Nesse caso o `type` de cada
420
+ // especificador sai, porque os dois juntos são erro de compilação (TS2206).
421
+ const allTypeOnly =
422
+ !defaultIsType &&
423
+ parts.length === 0 &&
424
+ namedParts.length > 0 &&
425
+ namedParts.every((part) => part.startsWith('type '));
426
+ const emittedNamed = allTypeOnly ? namedParts.map((part) => part.slice('type '.length)) : namedParts;
427
+
428
+ if (emittedNamed.length > 0) {
429
+ parts.push(`{ ${emittedNamed.join(', ')} }`);
430
+ }
431
+
432
+ if (parts.length === 0) {
433
+ return null;
434
+ }
435
+
436
+ const typePrefix = clause.isTypeOnly || allTypeOnly ? 'type ' : '';
437
+ return `import ${typePrefix}${parts.join(', ')} from ${moduleSpecifier};`;
438
+ }
439
+
440
+ function importsFor(
441
+ sourceFile: ts.SourceFile,
442
+ entries: DeclEntry[],
443
+ sourceDir: string,
444
+ outDir: string,
445
+ importOverrides?: Map<string, Set<string>>
446
+ ): string {
447
+ const usedNames = new Set<string>();
448
+ for (const entry of entries) {
449
+ for (const name of importOverrides?.get(entry.key) ?? entry.imports) {
450
+ usedNames.add(name);
451
+ }
452
+ }
453
+
454
+ const lines: string[] = [];
455
+ for (const statement of sourceFile.statements) {
456
+ if (!ts.isImportDeclaration(statement)) {
457
+ continue;
458
+ }
459
+ const emitted = emitImportDeclaration(statement, usedNames, sourceDir, outDir);
460
+ if (emitted) {
461
+ lines.push(emitted);
462
+ }
463
+ }
464
+
465
+ return lines.join('\n');
466
+ }
467
+
468
+ function renderDeclaration(entry: DeclEntry, sourceFile: ts.SourceFile): string {
469
+ if (ts.isVariableDeclaration(entry.node)) {
470
+ const list = (entry.statement as ts.VariableStatement).declarationList;
471
+ const flags = ts.getCombinedNodeFlags(list);
472
+ const keyword = flags & ts.NodeFlags.Const ? 'const' : flags & ts.NodeFlags.Let ? 'let' : 'var';
473
+ const exportPrefix = entry.isExported ? 'export ' : '';
474
+ return `${exportPrefix}${keyword} ${entry.node.getText(sourceFile)};`;
475
+ }
476
+
477
+ return entry.node.getText(sourceFile);
478
+ }
479
+
480
+ function composeArtifact(banners: string[], importLines: string[], body: string): string {
481
+ const header = [...banners, ...importLines.filter((line) => line.length > 0)].join('\n');
482
+ return `${header}\n\n${body}\n`;
483
+ }
484
+
485
+ function unwrapPromiseType(typeText: string): string {
486
+ const match = typeText.match(/^Promise<([\s\S]+)>$/);
487
+ return match ? match[1].trim() : `Awaited<${typeText}>`;
488
+ }
489
+
490
+ function renderRpcStub(entry: DeclEntry, endpoint: string, sourceFile: ts.SourceFile): string {
491
+ const fn = entry.node as ts.FunctionDeclaration;
492
+ const payload = fn.parameters[0];
493
+ const payloadType = payload?.type ? payload.type.getText(sourceFile) : 'unknown';
494
+ const rawReturn = fn.type ? fn.type.getText(sourceFile) : 'Promise<unknown>';
495
+ const awaited = unwrapPromiseType(rawReturn);
496
+
497
+ return [
498
+ `export async function ${entry.key}(payload: ${payloadType}): ${rawReturn} {`,
499
+ ` return rpcCall<${awaited}>(${JSON.stringify(endpoint)}, payload);`,
500
+ `}`
501
+ ].join('\n');
502
+ }
503
+
504
+ function artifactFileName(kind: ArtifactKind): string {
505
+ if (kind === 'shared') {
506
+ return 'shared.tsx';
507
+ }
508
+ return kind === 'server' ? 'server.ts' : 'client.tsx';
509
+ }
510
+
511
+ export function artifactDirectory(baseDir: string, sliceName: string): string {
512
+ return path.join(baseDir, '.synapse', 'dist', sliceName);
513
+ }
514
+
515
+ /**
516
+ * Uma fatia não importa outra. O que é comum vira um módulo em `src/shared/` que recebe o
517
+ * `db` por parâmetro — a varredura é transitiva porque um helper pode, sem querer, trazer a
518
+ * outra fatia de volta para o bundle errado.
519
+ */
520
+ function findSliceImport(
521
+ program: ts.Program,
522
+ root: ts.SourceFile,
523
+ options: ts.CompilerOptions,
524
+ host: ts.ModuleResolutionHost
525
+ ): string | null {
526
+ const seen = new Set<string>([path.normalize(root.fileName)]);
527
+ const queue: ts.SourceFile[] = [root];
528
+
529
+ while (queue.length > 0) {
530
+ const current = queue.shift();
531
+ if (!current) {
532
+ continue;
533
+ }
534
+
535
+ for (const statement of current.statements) {
536
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
537
+ continue;
538
+ }
539
+
540
+ const resolved = ts.resolveModuleName(
541
+ statement.moduleSpecifier.text,
542
+ current.fileName,
543
+ options,
544
+ host
545
+ ).resolvedModule;
546
+ if (!resolved) {
547
+ continue;
548
+ }
549
+
550
+ const target = path.normalize(resolved.resolvedFileName);
551
+ if (target.endsWith('.slice.tsx')) {
552
+ return target;
553
+ }
554
+ if (seen.has(target)) {
555
+ continue;
556
+ }
557
+
558
+ const next = program.getSourceFile(resolved.resolvedFileName);
559
+ if (next) {
560
+ seen.add(target);
561
+ queue.push(next);
562
+ }
563
+ }
564
+ }
565
+
566
+ return null;
567
+ }
568
+
569
+ export function splitSlice(sliceFilePath: string, baseDir: string = process.cwd()): Result<SplitResult, SplitError> {
570
+ if (!fs.existsSync(sliceFilePath)) {
571
+ return Err({
572
+ code: 'SLICE_NOT_FOUND',
573
+ message: `Fatia não encontrada: ${sliceFilePath}`,
574
+ candidates: [sliceFilePath]
575
+ });
576
+ }
577
+
578
+ const options = projectOptions(path.dirname(sliceFilePath));
579
+ const host = ts.createCompilerHost(options);
580
+ const program = ts.createProgram([sliceFilePath], options, host);
581
+ const sourceFile = program.getSourceFile(sliceFilePath);
582
+
583
+ if (!sourceFile) {
584
+ return Err({
585
+ code: 'UNPARSED',
586
+ message: `Não foi possível ler a AST de ${sliceFilePath}`,
587
+ candidates: [sliceFilePath]
588
+ });
589
+ }
590
+
591
+ const illegalImport = findSliceImport(program, sourceFile, options, host);
592
+ if (illegalImport) {
593
+ return Err({
594
+ code: 'SLICE_IMPORTS_SLICE',
595
+ message:
596
+ `Uma fatia não importa outra: ${path.basename(sliceFilePath)} alcança ` +
597
+ `${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.',
599
+ candidates: [illegalImport]
600
+ });
601
+ }
602
+
603
+ const checker = program.getTypeChecker();
604
+ const entries = collectDeclarations(sourceFile, checker);
605
+
606
+ const serverRoots = rootsFor(entries, SERVER_ROOT_SUFFIXES, [SLICE_DDL_EXPORT, SLICE_META_EXPORT]);
607
+ const clientRoots = rootsFor(entries, CLIENT_ROOTS, []);
608
+
609
+ if (serverRoots.length === 0 && clientRoots.length === 0) {
610
+ return Err({
611
+ code: 'NO_ROOTS',
612
+ message:
613
+ `Nenhuma Server Action (*Action) ou componente (*Trigger|View|Form|Component) exportado em ` +
614
+ `${path.basename(sliceFilePath)}.`,
615
+ candidates: [...entries.keys()]
616
+ });
617
+ }
618
+
619
+ const serverSet = reachableFrom(serverRoots, entries);
620
+
621
+ const stubSignatures = new Map<string, { dependencies: Set<string>; imports: Set<string> }>();
622
+ for (const key of serverRoots) {
623
+ const entry = entries.get(key);
624
+ if (entry) {
625
+ stubSignatures.set(key, signatureReferences(entry, sourceFile, checker));
626
+ }
627
+ }
628
+
629
+ const stubSignatureDependencies = new Map<string, Set<string>>();
630
+ const stubSignatureImports = new Map<string, Set<string>>();
631
+ for (const [key, signature] of stubSignatures) {
632
+ stubSignatureDependencies.set(key, signature.dependencies);
633
+ stubSignatureImports.set(key, signature.imports);
634
+ }
635
+
636
+ const clientSet = reachableFrom(clientRoots, entries, stubSignatureDependencies);
637
+
638
+ // Type contracts are erased at runtime and therefore safe on both sides.
639
+ // Values referenced by those types (e.g. TypeBox schemas behind `typeof X`)
640
+ // are promoted alongside them so the shared module stays self-contained.
641
+ const shared = new Set<string>();
642
+ const promotionQueue: string[] = [];
643
+ for (const key of serverSet) {
644
+ if (clientSet.has(key) && entries.get(key)?.isTypeOnly) {
645
+ shared.add(key);
646
+ promotionQueue.push(key);
647
+ }
648
+ }
649
+
650
+ while (promotionQueue.length > 0) {
651
+ const key = promotionQueue.pop() as string;
652
+ const entry = entries.get(key);
653
+ if (!entry) {
654
+ continue;
655
+ }
656
+ for (const dependency of entry.dependencies) {
657
+ const dependencyEntry = entries.get(dependency);
658
+ if (!dependencyEntry || shared.has(dependency)) {
659
+ continue;
660
+ }
661
+ shared.add(dependency);
662
+ promotionQueue.push(dependency);
663
+ }
664
+ }
665
+
666
+ const sharedEntries: DeclEntry[] = [];
667
+ const serverEntries: DeclEntry[] = [];
668
+ const clientEntries: DeclEntry[] = [];
669
+ const stubbedKeys = serverRoots.filter((key) => clientSet.has(key));
670
+
671
+ for (const entry of [...entries.values()].sort((a, b) => a.position - b.position)) {
672
+ if (TEST_ONLY_NAMES.includes(entry.key)) {
673
+ continue;
674
+ }
675
+
676
+ if (shared.has(entry.key)) {
677
+ sharedEntries.push(entry);
678
+ continue;
679
+ }
680
+
681
+ if (serverSet.has(entry.key)) {
682
+ serverEntries.push(entry);
683
+ if (clientSet.has(entry.key)) {
684
+ clientEntries.push(entry);
685
+ }
686
+ } else if (clientSet.has(entry.key)) {
687
+ clientEntries.push(entry);
688
+ }
689
+ }
690
+
691
+ const sliceName = path.basename(sliceFilePath, '.slice.tsx');
692
+ const relativePath = path.relative(baseDir, sliceFilePath);
693
+ const endpoint = `/_synapse/rpc/${sliceName}`;
694
+ const artifacts: SplitArtifact[] = [];
695
+ const outDir = artifactDirectory(baseDir, sliceName);
696
+ const sourceDir = path.dirname(sliceFilePath);
697
+
698
+ if (sharedEntries.length > 0) {
699
+ artifacts.push({
700
+ kind: 'shared',
701
+ fileName: artifactFileName('shared'),
702
+ code: composeArtifact(
703
+ [
704
+ `// [SYNAPSE-JS SHARED TARGET] AUTO-SPLIT FROM ${relativePath}`,
705
+ `// TYPE CONTRACTS AND PURE DECLARATIONS SHARED BY SERVER AND CLIENT`
706
+ ],
707
+ [importsFor(sourceFile, sharedEntries, sourceDir, outDir)],
708
+ sharedEntries.map((entry) => renderDeclaration(entry, sourceFile)).join('\n\n')
709
+ )
710
+ });
711
+ }
712
+
713
+ const serverSharedImport =
714
+ sharedEntries.length > 0 ? `import { ${sharedEntries.map((e) => e.key).join(', ')} } from './shared';` : '';
715
+
716
+ artifacts.push({
717
+ kind: 'server',
718
+ fileName: artifactFileName('server'),
719
+ code: composeArtifact(
720
+ [
721
+ `// [SYNAPSE-JS SERVER TARGET] AUTO-SPLIT FROM ${relativePath}`,
722
+ `// DATABASE ACCESS, SCHEMA DDL AND SERVER ACTIONS`
723
+ ],
724
+ [importsFor(sourceFile, serverEntries, sourceDir, outDir), serverSharedImport],
725
+ serverEntries.map((entry) => renderDeclaration(entry, sourceFile)).join('\n\n')
726
+ )
727
+ });
728
+
729
+ const clientSharedImport =
730
+ sharedEntries.length > 0 ? `import { ${sharedEntries.map((e) => e.key).join(', ')} } from './shared';` : '';
731
+ const clientRpcImport = stubbedKeys.length > 0 ? `import { rpcCall } from 'synapsejs/client';` : '';
732
+
733
+ artifacts.push({
734
+ kind: 'client',
735
+ fileName: artifactFileName('client'),
736
+ code: composeArtifact(
737
+ [
738
+ `// [SYNAPSE-JS CLIENT TARGET] AUTO-SPLIT FROM ${relativePath}`,
739
+ `// REACT UI AND TRANSPARENT RPC STUBS (NO DATABASE ACCESS)`
740
+ ],
741
+ [
742
+ importsFor(sourceFile, clientEntries, sourceDir, outDir, stubSignatureImports),
743
+ clientSharedImport,
744
+ clientRpcImport
745
+ ],
746
+ clientEntries
747
+ .map((entry) =>
748
+ stubbedKeys.includes(entry.key)
749
+ ? renderRpcStub(entry, endpoint, sourceFile)
750
+ : renderDeclaration(entry, sourceFile)
751
+ )
752
+ .join('\n\n')
753
+ )
754
+ });
755
+
756
+ return Ok({ sliceName, artifacts });
757
+ }
758
+
759
+ export function writeSplitArtifacts(result: SplitResult, outDir: string): string[] {
760
+ fs.mkdirSync(outDir, { recursive: true });
761
+
762
+ return result.artifacts.map((artifact) => {
763
+ const target = path.join(outDir, artifact.fileName);
764
+ fs.writeFileSync(target, artifact.code, 'utf-8');
765
+ return target;
766
+ });
767
+ }
768
+
769
+ /**
770
+ * Gate 1: the emitted modules must typecheck as a unit.
771
+ * Gate 2: the client module must not reference server-only symbols.
772
+ */
773
+ export function verifySplit(result: SplitResult, outDir: string): SplitVerification {
774
+ const diagnostics: SplitDiagnostic[] = [];
775
+ const leaks: string[] = [];
776
+
777
+ const virtualFiles = new Map<string, string>();
778
+ for (const artifact of result.artifacts) {
779
+ virtualFiles.set(path.normalize(path.join(outDir, artifact.fileName)), artifact.code);
780
+ }
781
+
782
+ const options = projectOptions(outDir);
783
+ const host = ts.createCompilerHost(options);
784
+ const originalGetSourceFile = host.getSourceFile.bind(host);
785
+ const originalFileExists = host.fileExists.bind(host);
786
+ const originalReadFile = host.readFile.bind(host);
787
+
788
+ host.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => {
789
+ const virtual = virtualFiles.get(path.normalize(fileName));
790
+ if (virtual !== undefined) {
791
+ return ts.createSourceFile(fileName, virtual, languageVersion, true);
792
+ }
793
+ return originalGetSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile);
794
+ };
795
+ host.fileExists = (fileName) => virtualFiles.has(path.normalize(fileName)) || originalFileExists(fileName);
796
+ host.readFile = (fileName) => virtualFiles.get(path.normalize(fileName)) ?? originalReadFile(fileName);
797
+
798
+ const program = ts.createProgram([...virtualFiles.keys()], options, host);
799
+ const virtualPaths = new Set([...virtualFiles.keys()]);
800
+
801
+ for (const diagnostic of ts.getPreEmitDiagnostics(program)) {
802
+ if (!diagnostic.file || !virtualPaths.has(path.normalize(diagnostic.file.fileName))) {
803
+ continue;
804
+ }
805
+
806
+ const position =
807
+ diagnostic.start !== undefined ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start) : null;
808
+
809
+ diagnostics.push({
810
+ file: path.relative(outDir, diagnostic.file.fileName),
811
+ line: position ? position.line + 1 : 0,
812
+ column: position ? position.character + 1 : 0,
813
+ code: `TS${diagnostic.code}`,
814
+ message: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')
815
+ });
816
+ }
817
+
818
+ const clientArtifact = result.artifacts.find((artifact) => artifact.kind === 'client');
819
+ if (clientArtifact) {
820
+ // Valor importado do índice do pacote arrasta sqlite/postgres para o browser:
821
+ // o bundle nem constrói. A entrada de cliente existe para isso.
822
+ if (/\bimport\s+(?!type\b)[^;]*from\s*['"]synapsejs['"]/.test(clientArtifact.code)) {
823
+ leaks.push('pacote-raiz-no-cliente');
824
+ }
825
+
826
+ for (const { label, pattern } of SERVER_ONLY_PATTERNS) {
827
+ if (pattern.test(clientArtifact.code)) {
828
+ leaks.push(label);
829
+ }
830
+ }
831
+ }
832
+
833
+ return {
834
+ status: diagnostics.length === 0 && leaks.length === 0 ? 'PASS' : 'FAIL',
835
+ diagnostics,
836
+ leaks
837
+ };
838
+ }