@ismaelsoilet/synapsejs 1.4.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +108 -10
  2. package/bin/synapse.ts +263 -218
  3. package/package.json +1 -1
  4. package/src/client/components.tsx +26 -9
  5. package/src/client/outlet.tsx +40 -0
  6. package/src/client/sub-slice.tsx +45 -0
  7. package/src/client/use-websocket.ts +146 -0
  8. package/src/client.ts +12 -0
  9. package/src/compiler/client-bundler.ts +17 -7
  10. package/src/compiler/context-bench.ts +7 -1
  11. package/src/compiler/contract.ts +31 -4
  12. package/src/compiler/db-schema-generator.ts +39 -8
  13. package/src/compiler/fields-parser.ts +28 -1
  14. package/src/compiler/impact-analyzer.ts +31 -1
  15. package/src/compiler/migration-runner.ts +144 -11
  16. package/src/compiler/optional-coverage.ts +137 -0
  17. package/src/compiler/oracle-runner.ts +8 -3
  18. package/src/compiler/scaffolder.ts +174 -3
  19. package/src/compiler/schema-dag.ts +6 -3
  20. package/src/compiler/slice-discovery.ts +8 -1
  21. package/src/compiler/slice-splitter.ts +67 -6
  22. package/src/compiler/slice-templates.ts +10 -0
  23. package/src/compiler/standalone-builder.ts +16 -2
  24. package/src/core/action-context.ts +101 -8
  25. package/src/core/cache.ts +139 -0
  26. package/src/core/config.ts +124 -10
  27. package/src/core/database-client.ts +68 -6
  28. package/src/core/index.ts +5 -0
  29. package/src/core/path-guard.ts +105 -0
  30. package/src/core/postgres-client.ts +6 -0
  31. package/src/core/postgres-queue.ts +45 -7
  32. package/src/core/query-builder.ts +75 -60
  33. package/src/core/session-context.ts +1 -1
  34. package/src/core/session-token.ts +115 -8
  35. package/src/core/sqlite-client.ts +26 -1
  36. package/src/core/storage.ts +61 -24
  37. package/src/core/topics.ts +84 -0
  38. package/src/core/webhook-signature.ts +151 -0
  39. package/src/core/websocket.ts +30 -0
  40. package/src/index.ts +10 -2
  41. package/src/mcp/server.ts +78 -173
  42. package/src/mcp/tools.ts +238 -0
  43. package/src/project-status.ts +330 -0
  44. package/src/runtime/discovery-rules.ts +20 -1
  45. package/src/runtime/event-hub.ts +6 -1
  46. package/src/runtime/network-guard.ts +182 -0
  47. package/src/runtime/queue-engine.ts +102 -21
  48. package/src/runtime/rate-limiter.ts +24 -0
  49. package/src/runtime/server.ts +1380 -251
  50. package/src/runtime/uploads.ts +199 -19
  51. package/src/version.ts +11 -0
  52. package/templates/starter/.jev.json +1 -1
  53. package/templates/starter/tsconfig.json +1 -0
@@ -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);
@@ -261,9 +385,15 @@ export function extractSliceSchema(sourceFile: ts.SourceFile): string | null {
261
385
  return ddl;
262
386
  }
263
387
 
388
+ export interface RunMigrationsOptions {
389
+ /** Consult the Jev abort gate after a failure (default: disabled). */
390
+ enableJevGate?: boolean;
391
+ }
392
+
264
393
  export async function runSliceMigrations(
265
394
  baseDir: string = process.cwd(),
266
- customDb?: DatabaseClient
395
+ customDb?: DatabaseClient,
396
+ options: RunMigrationsOptions = {}
267
397
  ): Promise<MigrationReport> {
268
398
  const db = customDb || getDatabase();
269
399
  const isPostgres = db instanceof PostgresDatabaseClient;
@@ -394,20 +524,23 @@ export async function runSliceMigrations(
394
524
  status: 'APPLIED',
395
525
  statementsApplied: pending.length
396
526
  });
397
- } catch (err: any) {
527
+ } catch (err: unknown) {
398
528
  hasFailure = true;
529
+ const errMessage = err instanceof Error ? err.message : String(err);
399
530
  migrations.push({
400
531
  slice: sliceName,
401
532
  filePath: path.relative(baseDir, filePath),
402
533
  status: 'FAILED',
403
534
  statementsApplied: 0,
404
- error: err.message
535
+ error: errMessage
405
536
  });
406
537
  }
407
538
  }
408
539
 
540
+ // Opt-in (default: disabled): the abort gate consults an external model provider
541
+ // when one is configured, so a programmatic caller asks for it explicitly.
409
542
  let abortSignal: AbortGateResult | undefined;
410
- if (hasFailure) {
543
+ if (hasFailure && options?.enableJevGate === true) {
411
544
  try {
412
545
  const failureDetails = migrations
413
546
  .filter((m) => m.status === 'FAILED')
@@ -0,0 +1,137 @@
1
+ /**
2
+ * SynapseJS - Optional Contract Item Coverage
3
+ *
4
+ * The slice authoring contract has optional items — webhook, background job,
5
+ * websocket definition, cache definition, metadata generation, broadcast and
6
+ * subscription. Prose cannot prove they work; a committed slice can. This module
7
+ * reports which optional item each shipped application exercises and which ones
8
+ * nothing exercises, so a capability cannot be advertised on a template assertion.
9
+ */
10
+
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { findSliceFiles, resolveSlicesDir } from './slice-discovery';
14
+
15
+ export interface OptionalItemCoverage {
16
+ item: string;
17
+ /** The committed slice that exercises it, or null when nothing does. */
18
+ slice: string | null;
19
+ }
20
+
21
+ export interface OptionalItemReport {
22
+ status: 'PASS' | 'FAIL';
23
+ operation: 'OPTIONAL_ITEM_COVERAGE';
24
+ appDir: string;
25
+ totalSlices: number;
26
+ items: OptionalItemCoverage[];
27
+ /** The optional items no committed slice exercises. Empty is the passing state. */
28
+ unexercised: string[];
29
+ /** Present when slice discovery failed. */
30
+ code?: string;
31
+ message?: string;
32
+ candidates?: string[];
33
+ }
34
+
35
+ interface Detector {
36
+ item: string;
37
+ matches: (filePath: string, source: string) => boolean;
38
+ }
39
+
40
+ function exportsName(source: string, suffix: string): boolean {
41
+ return new RegExp(`export\\s+(?:async\\s+)?(?:function|const|class|let|var)\\s+\\w*${suffix}\\b`).test(source);
42
+ }
43
+
44
+ const DETECTORS: Detector[] = [
45
+ {
46
+ item: 'webhook',
47
+ matches: (_file, source) => exportsName(source, 'Webhook')
48
+ },
49
+ {
50
+ item: 'background job',
51
+ matches: (_file, source) => exportsName(source, 'Job') || /\bdefineJob\s*[<(]/.test(source)
52
+ },
53
+ {
54
+ item: 'websocket definition',
55
+ matches: (_file, source) => exportsName(source, 'Socket') || /\bdefineSocket\s*[<(]/.test(source)
56
+ },
57
+ {
58
+ item: 'cache definition',
59
+ matches: (_file, source) => exportsName(source, 'Cache') || /\bdefineCache\s*\(/.test(source)
60
+ },
61
+ {
62
+ item: 'metadata generation',
63
+ matches: (_file, source) => /\bsliceMeta\b/.test(source) || exportsName(source, 'Meta')
64
+ },
65
+ {
66
+ item: 'broadcast',
67
+ matches: (_file, source) => /\.broadcast\s*\(/.test(source)
68
+ },
69
+ {
70
+ item: 'subscription',
71
+ matches: (_file, source) => /\buseSubscription\s*\(/.test(source) || /\bdefineTopic\s*\(/.test(source)
72
+ }
73
+ ];
74
+
75
+ export function analyzeOptionalItemCoverage(appDir: string = process.cwd()): OptionalItemReport {
76
+ const resolution = resolveSlicesDir(appDir);
77
+
78
+ if (!resolution.ok) {
79
+ return {
80
+ status: 'FAIL',
81
+ operation: 'OPTIONAL_ITEM_COVERAGE',
82
+ appDir,
83
+ totalSlices: 0,
84
+ items: DETECTORS.map((detector) => ({ item: detector.item, slice: null })),
85
+ unexercised: DETECTORS.map((detector) => detector.item),
86
+ code: resolution.error.code,
87
+ message: resolution.error.message,
88
+ candidates: resolution.error.candidates
89
+ };
90
+ }
91
+
92
+ const sliceFiles = findSliceFiles(resolution.value.slicesDir);
93
+ const items: OptionalItemCoverage[] = DETECTORS.map((detector) => {
94
+ for (const filePath of sliceFiles) {
95
+ try {
96
+ const source = fs.readFileSync(filePath, 'utf-8');
97
+
98
+ if (detector.matches(filePath, source)) {
99
+ return { item: detector.item, slice: path.basename(filePath, '.slice.tsx') };
100
+ }
101
+ } catch {
102
+ // Unreadable slice: it cannot be the evidence, and discovery already reported it.
103
+ }
104
+ }
105
+
106
+ return { item: detector.item, slice: null };
107
+ });
108
+
109
+ const unexercised = items.filter((entry) => entry.slice === null).map((entry) => entry.item);
110
+
111
+ return {
112
+ status: unexercised.length === 0 ? 'PASS' : 'FAIL',
113
+ operation: 'OPTIONAL_ITEM_COVERAGE',
114
+ appDir,
115
+ totalSlices: sliceFiles.length,
116
+ items,
117
+ unexercised
118
+ };
119
+ }
120
+
121
+ /** The union of optional items covered across several shipped applications. */
122
+ export function unionCoverage(reports: OptionalItemReport[]): { covered: string[]; unexercised: string[] } {
123
+ const covered = new Set<string>();
124
+
125
+ for (const report of reports) {
126
+ for (const entry of report.items) {
127
+ if (entry.slice) {
128
+ covered.add(entry.item);
129
+ }
130
+ }
131
+ }
132
+
133
+ return {
134
+ covered: Array.from(covered),
135
+ unexercised: DETECTORS.map((detector) => detector.item).filter((item) => !covered.has(item))
136
+ };
137
+ }
@@ -157,6 +157,11 @@ export interface OracleRunOptions {
157
157
  /** Injected by tests: runs a command and returns its exit code and output. */
158
158
  spawn?: (args: string[], cwd: string) => Promise<{ exitCode: number; output: string }>;
159
159
  /** Enable Jev System One semantic test triage on failures. Default: true */
160
+ /**
161
+ * Opt-in (default: disabled). The triage consults an external model provider when
162
+ * one is configured, so a programmatic caller must ask for it explicitly; the CLI
163
+ * enables it only when the environment declares the gate.
164
+ */
160
165
  enableJevTriage?: boolean;
161
166
  }
162
167
 
@@ -246,7 +251,7 @@ export async function runSliceOracles(
246
251
 
247
252
  if (!fs.existsSync(reportPath)) {
248
253
  let triageResult: TestTriageResult | undefined;
249
- if (options.enableJevTriage !== false) {
254
+ if (options.enableJevTriage === true) {
250
255
  try {
251
256
  triageResult = await triageTestFailure(output.trim() || `O runner Bun test falhou com exit ${exitCode}`);
252
257
  } catch {}
@@ -280,7 +285,7 @@ export async function runSliceOracles(
280
285
 
281
286
  if (totalCases === 0) {
282
287
  let triageResult: TestTriageResult | undefined;
283
- if (options.enableJevTriage !== false) {
288
+ if (options.enableJevTriage === true) {
284
289
  try {
285
290
  triageResult = await triageTestFailure(`Nenhum invariante foi executado em ${results.length} fatia(s).`);
286
291
  } catch {}
@@ -301,7 +306,7 @@ export async function runSliceOracles(
301
306
 
302
307
  const isPass = passedSlices === results.length;
303
308
  let triageResult: TestTriageResult | undefined;
304
- if (!isPass && options.enableJevTriage !== false) {
309
+ if (!isPass && options.enableJevTriage === true) {
305
310
  try {
306
311
  const failedSummaries = results
307
312
  .filter((r) => !r.passed)
@@ -8,7 +8,14 @@
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import { Err, Ok, type Result } from '../core/machine-types';
11
- import { generateFormFieldsCode, generateSqlColumns, generateTypeBoxProperties, parseFields } from './fields-parser';
11
+ import { isInsideBase, validatePathSegment } from '../core/path-guard';
12
+ import {
13
+ generateFormFieldsCode,
14
+ generateSqlColumns,
15
+ generateTypeBoxProperties,
16
+ parseFields,
17
+ validateFieldNames
18
+ } from './fields-parser';
12
19
  import { resolveSlicesDir, type SlicesDirErrorCode } from './slice-discovery';
13
20
  import { generateOperationTemplate, type SliceTemplate } from './slice-templates';
14
21
 
@@ -71,6 +78,9 @@ export const sliceSchema = \`
71
78
  ${sqlCols},
72
79
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
73
80
  );
81
+
82
+ -- down:
83
+ DROP TABLE IF EXISTS ${tableName};
74
84
  \`;
75
85
 
76
86
  // ============================================================================
@@ -222,6 +232,9 @@ export const sliceSchema = \`
222
232
  email TEXT NOT NULL UNIQUE,
223
233
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
224
234
  );
235
+
236
+ -- down:
237
+ DROP TABLE IF EXISTS ${tableName};
225
238
  \`;
226
239
 
227
240
  // ============================================================================
@@ -409,7 +422,28 @@ export const sliceTests = {
409
422
  `;
410
423
  }
411
424
 
412
- export type ScaffoldErrorCode = SlicesDirErrorCode | 'SLICE_EXISTS' | 'WRITE_FAILED';
425
+ export type ScaffoldErrorCode =
426
+ | SlicesDirErrorCode
427
+ | 'SLICE_EXISTS'
428
+ | 'WRITE_FAILED'
429
+ | 'INVALID_PATH_SEGMENT'
430
+ | 'INVALID_FIELD_NAME';
431
+
432
+ /**
433
+ * One description per scaffolding failure code. The `Record` type makes a
434
+ * missing member a compile error, so the documented set cannot drift from the
435
+ * union the functions return.
436
+ */
437
+ export const SCAFFOLD_FAILURE_CODES: Record<ScaffoldErrorCode, string> = {
438
+ NO_SLICES_DIR: 'no src/slices directory was found; candidates lists every path examined',
439
+ AMBIGUOUS_SLICES_DIR:
440
+ 'more than one application owns a slices directory; set SYNAPSE_ROOT or run inside the target application',
441
+ SLICE_EXISTS: 'the target file already exists',
442
+ WRITE_FAILED: 'the filesystem refused the write',
443
+ INVALID_PATH_SEGMENT:
444
+ 'a domain, slice name, shared-module name or project directory is not a single safe path segment',
445
+ INVALID_FIELD_NAME: 'a field name or enum value in --fields is not a plain identifier'
446
+ };
413
447
 
414
448
  export interface ScaffoldError {
415
449
  code: ScaffoldErrorCode;
@@ -442,6 +476,47 @@ function resolveScaffoldTarget(baseDir: string): Result<string, ScaffoldError> {
442
476
  return Ok(path.join(path.resolve(baseDir), 'src', 'slices'));
443
477
  }
444
478
 
479
+ function segmentRejection(
480
+ kind: 'domain' | 'name',
481
+ value: string | undefined,
482
+ targetKind: string
483
+ ): ScaffoldError | null {
484
+ const validated = validatePathSegment(value);
485
+
486
+ if (validated.ok) {
487
+ return null;
488
+ }
489
+
490
+ return {
491
+ code: 'INVALID_PATH_SEGMENT',
492
+ message: `O ${kind === 'domain' ? 'domínio' : 'nome'} informado ('${value ?? ''}') não é um segmento de caminho válido para ${targetKind}. Use apenas letras, números, '-', '_' e '.', sem barras e sem '..'.`,
493
+ candidates: []
494
+ };
495
+ }
496
+
497
+ /**
498
+ * Field names are interpolated into generated DDL and generated TypeScript, so
499
+ * they are validated before a single byte is written: the migration runner is
500
+ * never handed a schema derived from an unchecked name.
501
+ */
502
+ function fieldRejection(fieldsSpec: string | undefined): ScaffoldError | null {
503
+ if (!fieldsSpec?.trim()) {
504
+ return null;
505
+ }
506
+
507
+ const validation = validateFieldNames(parseFields(fieldsSpec));
508
+
509
+ if (validation.ok) {
510
+ return null;
511
+ }
512
+
513
+ return {
514
+ code: 'INVALID_FIELD_NAME',
515
+ message: `O nome de campo '${validation.error}' não é um identificador válido. Campos devem casar com [A-Za-z_][A-Za-z0-9_]*.`,
516
+ candidates: []
517
+ };
518
+ }
519
+
445
520
  export function scaffoldSlice(
446
521
  domain: string,
447
522
  sliceName: string,
@@ -455,9 +530,35 @@ export function scaffoldSlice(
455
530
  return target;
456
531
  }
457
532
 
533
+ const domainRejection = segmentRejection('domain', domain, 'a fatia');
534
+
535
+ if (domainRejection) {
536
+ return Err(domainRejection);
537
+ }
538
+
539
+ const nameRejection = segmentRejection('name', sliceName, 'a fatia');
540
+
541
+ if (nameRejection) {
542
+ return Err(nameRejection);
543
+ }
544
+
545
+ const fieldsRejection = fieldRejection(fieldsSpec);
546
+
547
+ if (fieldsRejection) {
548
+ return Err(fieldsRejection);
549
+ }
550
+
458
551
  const targetDir = path.join(target.value, domain);
459
552
  const targetFile = path.join(targetDir, `${sliceName}.slice.tsx`);
460
553
 
554
+ if (!isInsideBase(target.value, targetFile)) {
555
+ return Err({
556
+ code: 'INVALID_PATH_SEGMENT',
557
+ message: `O caminho resolvido para a fatia '${sliceName}' sai do diretório de fatias da aplicação.`,
558
+ candidates: [targetFile]
559
+ });
560
+ }
561
+
461
562
  if (fs.existsSync(targetFile)) {
462
563
  return Err({
463
564
  code: 'SLICE_EXISTS',
@@ -467,11 +568,12 @@ export function scaffoldSlice(
467
568
  }
468
569
 
469
570
  try {
470
- fs.mkdirSync(targetDir, { recursive: true });
471
571
  const content =
472
572
  template === 'create'
473
573
  ? generateSliceTemplate(domain, sliceName, fieldsSpec)
474
574
  : generateOperationTemplate(domain, sliceName, template, fieldsSpec);
575
+
576
+ fs.mkdirSync(targetDir, { recursive: true });
475
577
  fs.writeFileSync(targetFile, content, 'utf-8');
476
578
  } catch (err) {
477
579
  return Err({
@@ -513,6 +615,75 @@ export function scaffoldCrud(
513
615
  return Ok(created);
514
616
  }
515
617
 
618
+ /**
619
+ * Scaffolds a safe shared domain module in `src/shared/<name>.ts`.
620
+ */
621
+ export function scaffoldShared(name: string, baseDir: string = process.cwd()): Result<string, ScaffoldError> {
622
+ const cleanName = name.replace(/\.ts$/, '');
623
+ const sharedDir = path.resolve(baseDir, 'src', 'shared');
624
+
625
+ const nameRejection = segmentRejection('name', cleanName, 'o módulo compartilhado');
626
+
627
+ if (nameRejection) {
628
+ return Err(nameRejection);
629
+ }
630
+
631
+ const targetFile = path.join(sharedDir, `${cleanName}.ts`);
632
+
633
+ if (!isInsideBase(sharedDir, targetFile)) {
634
+ return Err({
635
+ code: 'INVALID_PATH_SEGMENT',
636
+ message: `O caminho resolvido para o módulo compartilhado '${cleanName}' sai do diretório src/shared da aplicação.`,
637
+ candidates: [targetFile]
638
+ });
639
+ }
640
+
641
+ if (fs.existsSync(targetFile)) {
642
+ return Err({
643
+ code: 'SLICE_EXISTS',
644
+ message: `O módulo compartilhado "${cleanName}.ts" já existe em ${sharedDir}.`,
645
+ candidates: [targetFile]
646
+ });
647
+ }
648
+
649
+ const pascalName = toPascalCase(cleanName);
650
+ const content = `import type { DatabaseClient, Result } from 'synapsejs';
651
+ import { Ok, Err } from 'synapsejs';
652
+
653
+ /**
654
+ * Módulo compartilhado para o domínio ${cleanName}.
655
+ * Fatias não importam fatias — regras que envolvem múltiplas fatias ou transações cruzadas
656
+ * residem em src/shared/, recebem o DatabaseClient por parâmetro e abrem db.transaction().
657
+ */
658
+ export async function executeShared${pascalName}(
659
+ db: DatabaseClient,
660
+ params: Record<string, unknown> = {}
661
+ ): Promise<Result<{ success: boolean; data?: unknown }, string>> {
662
+ if (!db) {
663
+ return Err('NO_DATABASE');
664
+ }
665
+
666
+ return await db.transaction(async (tx) => {
667
+ // Implemente a lógica transacional entre fatias aqui usando tx
668
+ return Ok({ success: true, data: params });
669
+ });
670
+ }
671
+ `;
672
+
673
+ try {
674
+ fs.mkdirSync(sharedDir, { recursive: true });
675
+ fs.writeFileSync(targetFile, content, 'utf-8');
676
+ return Ok(targetFile);
677
+ } catch (err: unknown) {
678
+ const message = err instanceof Error ? err.message : String(err);
679
+ return Err({
680
+ code: 'WRITE_FAILED',
681
+ message: `Falha ao escrever módulo compartilhado: ${message}`,
682
+ candidates: [targetFile]
683
+ });
684
+ }
685
+ }
686
+
516
687
  if (import.meta.main) {
517
688
  const domain = process.argv[2] || 'core';
518
689
  const name = process.argv[3] || 'example-feature';
@@ -96,9 +96,12 @@ export function orderSlicesByDag(sliceFiles: string[]): string[] {
96
96
  for (const refTable of meta.tablesReferenced) {
97
97
  const parentFilePath = tableToSlice.get(refTable);
98
98
  if (parentFilePath && parentFilePath !== filePath) {
99
- // parentFilePath must run before filePath
100
- graph.get(parentFilePath)?.push(filePath);
101
- 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
+ }
102
105
  }
103
106
  }
104
107
  }
@@ -38,7 +38,14 @@ export function findSliceFiles(dir: string): string[] {
38
38
  }
39
39
 
40
40
  const results: string[] = [];
41
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
41
+ // `readdirSync` order is filesystem-dependent: on a fresh checkout it can differ from
42
+ // the developer's machine, which made generated artifacts (repo map, schema catalog,
43
+ // split order) drift between the two. Sorting makes every generated file reproducible.
44
+ const entries = fs
45
+ .readdirSync(dir, { withFileTypes: true })
46
+ .sort((left, right) => left.name.localeCompare(right.name));
47
+
48
+ for (const entry of entries) {
42
49
  const full = path.join(dir, entry.name);
43
50
  if (entry.isDirectory()) {
44
51
  results.push(...findSliceFiles(full));