@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.
- package/README.md +108 -10
- package/bin/synapse.ts +263 -218
- package/package.json +1 -1
- package/src/client/components.tsx +26 -9
- package/src/client/outlet.tsx +40 -0
- package/src/client/sub-slice.tsx +45 -0
- package/src/client/use-websocket.ts +146 -0
- package/src/client.ts +12 -0
- package/src/compiler/client-bundler.ts +17 -7
- package/src/compiler/context-bench.ts +7 -1
- package/src/compiler/contract.ts +31 -4
- package/src/compiler/db-schema-generator.ts +39 -8
- package/src/compiler/fields-parser.ts +28 -1
- package/src/compiler/impact-analyzer.ts +31 -1
- package/src/compiler/migration-runner.ts +144 -11
- package/src/compiler/optional-coverage.ts +137 -0
- package/src/compiler/oracle-runner.ts +8 -3
- package/src/compiler/scaffolder.ts +174 -3
- package/src/compiler/schema-dag.ts +6 -3
- package/src/compiler/slice-discovery.ts +8 -1
- package/src/compiler/slice-splitter.ts +67 -6
- package/src/compiler/slice-templates.ts +10 -0
- package/src/compiler/standalone-builder.ts +16 -2
- package/src/core/action-context.ts +101 -8
- package/src/core/cache.ts +139 -0
- package/src/core/config.ts +124 -10
- package/src/core/database-client.ts +68 -6
- package/src/core/index.ts +5 -0
- package/src/core/path-guard.ts +105 -0
- package/src/core/postgres-client.ts +6 -0
- package/src/core/postgres-queue.ts +45 -7
- package/src/core/query-builder.ts +75 -60
- package/src/core/session-context.ts +1 -1
- package/src/core/session-token.ts +115 -8
- package/src/core/sqlite-client.ts +26 -1
- package/src/core/storage.ts +61 -24
- package/src/core/topics.ts +84 -0
- package/src/core/webhook-signature.ts +151 -0
- package/src/core/websocket.ts +30 -0
- package/src/index.ts +10 -2
- package/src/mcp/server.ts +78 -173
- package/src/mcp/tools.ts +238 -0
- package/src/project-status.ts +330 -0
- package/src/runtime/discovery-rules.ts +20 -1
- package/src/runtime/event-hub.ts +6 -1
- package/src/runtime/network-guard.ts +182 -0
- package/src/runtime/queue-engine.ts +102 -21
- package/src/runtime/rate-limiter.ts +24 -0
- package/src/runtime/server.ts +1380 -251
- package/src/runtime/uploads.ts +199 -19
- package/src/version.ts +11 -0
- package/templates/starter/.jev.json +1 -1
- 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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
171
|
+
|
|
72
172
|
const upDdl = rawDdl.slice(0, splitIndex).trim();
|
|
73
|
-
const downDdl = rawDdl.slice(splitIndex +
|
|
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
|
-
//
|
|
211
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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 =
|
|
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)
|
|
101
|
-
|
|
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
|
-
|
|
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));
|