@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.
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/bin/synapse.ts +919 -0
- package/package.json +70 -0
- package/src/client/components.tsx +508 -0
- package/src/client/context.tsx +62 -0
- package/src/client/hooks.ts +152 -0
- package/src/client/i18n.ts +40 -0
- package/src/client.ts +66 -0
- package/src/compiler/agent-diagnostic-json.ts +113 -0
- package/src/compiler/ast-daemon-compressor.ts +190 -0
- package/src/compiler/client-bundler.ts +356 -0
- package/src/compiler/context-bench.ts +191 -0
- package/src/compiler/contract.ts +260 -0
- package/src/compiler/db-schema-generator.ts +306 -0
- package/src/compiler/fields-parser.ts +267 -0
- package/src/compiler/impact-analyzer.ts +279 -0
- package/src/compiler/migration-runner.ts +555 -0
- package/src/compiler/oracle-runner.ts +293 -0
- package/src/compiler/scaffolder.ts +528 -0
- package/src/compiler/schema-dag.ts +137 -0
- package/src/compiler/schema-drift.ts +184 -0
- package/src/compiler/slice-discovery.ts +154 -0
- package/src/compiler/slice-splitter.ts +838 -0
- package/src/compiler/slice-templates.ts +1527 -0
- package/src/compiler/standalone-builder.ts +117 -0
- package/src/core/action-context.ts +152 -0
- package/src/core/config.ts +77 -0
- package/src/core/database-client.ts +246 -0
- package/src/core/database-factory.ts +46 -0
- package/src/core/image-optimizer.ts +83 -0
- package/src/core/index.ts +14 -0
- package/src/core/jobs.ts +53 -0
- package/src/core/machine-types.ts +75 -0
- package/src/core/oracle.ts +20 -0
- package/src/core/postgres-client.ts +164 -0
- package/src/core/postgres-queue.ts +288 -0
- package/src/core/query-builder.ts +343 -0
- package/src/core/rpc-client.ts +70 -0
- package/src/core/session-context.ts +87 -0
- package/src/core/session-cookie.ts +81 -0
- package/src/core/session-token.ts +170 -0
- package/src/core/sqlite-client.ts +201 -0
- package/src/core/storage.ts +317 -0
- package/src/index.ts +133 -0
- package/src/mcp/server.ts +553 -0
- package/src/runtime/client-entry.ts +65 -0
- package/src/runtime/discovery-rules.ts +63 -0
- package/src/runtime/event-hub.ts +97 -0
- package/src/runtime/postgres-event-hub.ts +148 -0
- package/src/runtime/queue-engine.ts +255 -0
- package/src/runtime/rate-limiter.ts +152 -0
- package/src/runtime/server.ts +1550 -0
- package/src/runtime/uploads.ts +192 -0
- package/templates/starter/.dockerignore +7 -0
- package/templates/starter/Dockerfile +40 -0
- package/templates/starter/README.md +40 -0
- package/templates/starter/gitignore +6 -0
- package/templates/starter/package.json +34 -0
- package/templates/starter/src/slices/welcome/hello-world.slice.tsx +172 -0
- package/templates/starter/tsconfig.json +21 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - Slice Scaffolder for Autonomous AI Agents
|
|
3
|
+
*
|
|
4
|
+
* Generates an end-to-end atomic .slice.tsx file with zero boilerplate,
|
|
5
|
+
* including TypeBox schema, Result types, server action, React UI form, and Fast-Check PBT tests.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import { Err, Ok, type Result } from '../core/machine-types';
|
|
11
|
+
import { generateFormFieldsCode, generateSqlColumns, generateTypeBoxProperties, parseFields } from './fields-parser';
|
|
12
|
+
import { resolveSlicesDir, type SlicesDirErrorCode } from './slice-discovery';
|
|
13
|
+
import { generateOperationTemplate, type SliceTemplate } from './slice-templates';
|
|
14
|
+
|
|
15
|
+
export type { SliceTemplate };
|
|
16
|
+
|
|
17
|
+
export function toPascalCase(str: string): string {
|
|
18
|
+
return str
|
|
19
|
+
.split(/[-_]/)
|
|
20
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
21
|
+
.join('');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function toCamelCase(str: string): string {
|
|
25
|
+
const pascal = toPascalCase(str);
|
|
26
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function generateFieldsSliceTemplate(domain: string, sliceName: string, fieldsSpec: string): string {
|
|
30
|
+
const pascal = toPascalCase(sliceName);
|
|
31
|
+
const camel = toCamelCase(sliceName);
|
|
32
|
+
const tableName = domain.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
33
|
+
const inputSchemaName = `${pascal}InputSchema`;
|
|
34
|
+
const inputTypeName = `${pascal}Input`;
|
|
35
|
+
const outputTypeName = `${pascal}Output`;
|
|
36
|
+
const actionName = `${camel}Action`;
|
|
37
|
+
const triggerName = `${pascal}Trigger`;
|
|
38
|
+
|
|
39
|
+
const fields = parseFields(fieldsSpec);
|
|
40
|
+
const tbProps = generateTypeBoxProperties(fields);
|
|
41
|
+
const sqlCols = generateSqlColumns(fields);
|
|
42
|
+
const formFields = generateFormFieldsCode(fields);
|
|
43
|
+
|
|
44
|
+
return `import React, { useState } from 'react';
|
|
45
|
+
import { Type, Static } from '@sinclair/typebox';
|
|
46
|
+
import { Value } from '@sinclair/typebox/value';
|
|
47
|
+
import * as fc from 'fast-check';
|
|
48
|
+
import {
|
|
49
|
+
type DatabaseClient,
|
|
50
|
+
Result,
|
|
51
|
+
Ok,
|
|
52
|
+
Err,
|
|
53
|
+
MockDatabaseClient,
|
|
54
|
+
type SessionContext,
|
|
55
|
+
DataForm,
|
|
56
|
+
Card
|
|
57
|
+
} from 'synapsejs';
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// 1. CONTRATO DE ENTRADA JIT (TypeBox)
|
|
61
|
+
// ============================================================================
|
|
62
|
+
export const ${inputSchemaName} = Type.Object({
|
|
63
|
+
${tbProps}
|
|
64
|
+
});
|
|
65
|
+
export type ${inputTypeName} = Static<typeof ${inputSchemaName}>;
|
|
66
|
+
|
|
67
|
+
// DDL Schema Declarativo da Fatia (Auto-Migrado pelo Synapse)
|
|
68
|
+
export const sliceSchema = \`
|
|
69
|
+
CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
70
|
+
id TEXT PRIMARY KEY,
|
|
71
|
+
${sqlCols},
|
|
72
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
73
|
+
);
|
|
74
|
+
\`;
|
|
75
|
+
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// 2. MODELAGEM ESTRITA DO DOMÍNIO (Result<T, E>)
|
|
78
|
+
// ============================================================================
|
|
79
|
+
export type ${outputTypeName} = Result<
|
|
80
|
+
{ id: string } & ${inputTypeName},
|
|
81
|
+
'INVALID_SCHEMA' | 'NO_DATABASE' | 'PERSISTENCE_FAILED'
|
|
82
|
+
>;
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// 3. EXECUÇÃO DE SERVIDOR PURA (Server Action)
|
|
86
|
+
// ============================================================================
|
|
87
|
+
export async function ${actionName}(
|
|
88
|
+
payload: unknown,
|
|
89
|
+
db?: DatabaseClient,
|
|
90
|
+
session?: SessionContext
|
|
91
|
+
): Promise<${outputTypeName}> {
|
|
92
|
+
if (!db) {
|
|
93
|
+
return Err('NO_DATABASE');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!Value.Check(${inputSchemaName}, payload)) {
|
|
97
|
+
return Err('INVALID_SCHEMA');
|
|
98
|
+
}
|
|
99
|
+
const input = payload as ${inputTypeName};
|
|
100
|
+
|
|
101
|
+
const generatedId = crypto.randomUUID();
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const inserted = await db.insert<{ id: string } & ${inputTypeName}>('${tableName}', {
|
|
105
|
+
id: generatedId,
|
|
106
|
+
...input
|
|
107
|
+
});
|
|
108
|
+
return Ok(inserted);
|
|
109
|
+
} catch (_err) {
|
|
110
|
+
return Err('PERSISTENCE_FAILED');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// 4. VISUALIZAÇÃO INTERATIVA DA UI (React)
|
|
116
|
+
// ============================================================================
|
|
117
|
+
export interface ${pascal}TriggerProps {
|
|
118
|
+
onSubmitAction?: (payload: unknown) => Promise<${outputTypeName}>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const FORM_FIELDS = ${formFields};
|
|
122
|
+
|
|
123
|
+
export function ${triggerName}({ onSubmitAction }: ${pascal}TriggerProps) {
|
|
124
|
+
const [feedback, setFeedback] = useState<string | null>(null);
|
|
125
|
+
|
|
126
|
+
const handleSubmit = async (formData: Record<string, unknown>) => {
|
|
127
|
+
if (!onSubmitAction) return;
|
|
128
|
+
const res = await onSubmitAction(formData);
|
|
129
|
+
if (res.ok) {
|
|
130
|
+
setFeedback('Registro criado com sucesso! ID: ' + res.value.id);
|
|
131
|
+
} else {
|
|
132
|
+
setFeedback('Erro: ' + res.error);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<Card title="${pascal}" subtitle="Gerenciado por SynapseJS">
|
|
138
|
+
<DataForm
|
|
139
|
+
fields={FORM_FIELDS}
|
|
140
|
+
onSubmit={handleSubmit}
|
|
141
|
+
submitLabel="Salvar ${pascal}"
|
|
142
|
+
/>
|
|
143
|
+
{feedback && (
|
|
144
|
+
<div className="mt-4 p-3 rounded font-mono text-xs bg-slate-800 text-slate-300">
|
|
145
|
+
{feedback}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</Card>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ============================================================================
|
|
153
|
+
// 5. ORÁCULO DE AUTO-VERIFICAÇÃO PBT (Property-Based Testing)
|
|
154
|
+
// ============================================================================
|
|
155
|
+
export const sliceTests = {
|
|
156
|
+
description: 'Invariantes de ${sliceName}',
|
|
157
|
+
cases: [
|
|
158
|
+
{
|
|
159
|
+
name: 'payload inválido retorna INVALID_SCHEMA',
|
|
160
|
+
run: async () => {
|
|
161
|
+
const result = await ${actionName}({ invalidField: 123 }, new MockDatabaseClient());
|
|
162
|
+
if (result.ok || result.error !== 'INVALID_SCHEMA') {
|
|
163
|
+
throw new Error('esperava INVALID_SCHEMA');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: 'sem conexão de banco retorna NO_DATABASE',
|
|
169
|
+
run: async () => {
|
|
170
|
+
const result = await ${actionName}({});
|
|
171
|
+
if (result.ok || result.error !== 'NO_DATABASE') {
|
|
172
|
+
throw new Error('esperava NO_DATABASE');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
]
|
|
177
|
+
};
|
|
178
|
+
`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function generateSliceTemplate(domain: string, sliceName: string, fieldsSpec?: string): string {
|
|
182
|
+
if (fieldsSpec?.trim()) {
|
|
183
|
+
return generateFieldsSliceTemplate(domain, sliceName, fieldsSpec);
|
|
184
|
+
}
|
|
185
|
+
const pascal = toPascalCase(sliceName);
|
|
186
|
+
const camel = toCamelCase(sliceName);
|
|
187
|
+
const tableName = domain.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
188
|
+
const inputSchemaName = `${pascal}InputSchema`;
|
|
189
|
+
const inputTypeName = `${pascal}Input`;
|
|
190
|
+
const outputTypeName = `${pascal}Output`;
|
|
191
|
+
const actionName = `${camel}Action`;
|
|
192
|
+
const triggerName = `${pascal}Trigger`;
|
|
193
|
+
|
|
194
|
+
return `import React, { useState } from 'react';
|
|
195
|
+
import { Type, Static } from '@sinclair/typebox';
|
|
196
|
+
import { Value } from '@sinclair/typebox/value';
|
|
197
|
+
import * as fc from 'fast-check';
|
|
198
|
+
import {
|
|
199
|
+
type DatabaseClient,
|
|
200
|
+
Result,
|
|
201
|
+
Ok,
|
|
202
|
+
Err,
|
|
203
|
+
MockDatabaseClient,
|
|
204
|
+
type SessionContext
|
|
205
|
+
} from 'synapsejs';
|
|
206
|
+
|
|
207
|
+
// ============================================================================
|
|
208
|
+
// 1. CONTRATO DE ENTRADA JIT (TypeBox)
|
|
209
|
+
// ============================================================================
|
|
210
|
+
export const ${inputSchemaName} = Type.Object({
|
|
211
|
+
name: Type.String({ minLength: 2, maxLength: 100 }),
|
|
212
|
+
email: Type.String({ format: 'email' }),
|
|
213
|
+
metadata: Type.Optional(Type.String())
|
|
214
|
+
});
|
|
215
|
+
export type ${inputTypeName} = Static<typeof ${inputSchemaName}>;
|
|
216
|
+
|
|
217
|
+
// DDL Schema Declarativo da Fatia (Auto-Migrado pelo Synapse)
|
|
218
|
+
export const sliceSchema = \`
|
|
219
|
+
CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
220
|
+
id TEXT PRIMARY KEY,
|
|
221
|
+
name TEXT NOT NULL,
|
|
222
|
+
email TEXT NOT NULL UNIQUE,
|
|
223
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
224
|
+
);
|
|
225
|
+
\`;
|
|
226
|
+
|
|
227
|
+
// ============================================================================
|
|
228
|
+
// 2. MODELAGEM ESTRITA DO DOMÍNIO (Result<T, E>)
|
|
229
|
+
// ============================================================================
|
|
230
|
+
export type ${outputTypeName} = Result<
|
|
231
|
+
{ id: string; name: string; email: string; createdAt: string },
|
|
232
|
+
'INVALID_SCHEMA' | 'NO_DATABASE' | 'DUPLICATE_EMAIL' | 'PERSISTENCE_FAILED'
|
|
233
|
+
>;
|
|
234
|
+
|
|
235
|
+
// ============================================================================
|
|
236
|
+
// 3. EXECUÇÃO DE SERVIDOR PURA (Server Action)
|
|
237
|
+
// O banco é opcional para que o mesmo ponto de chamada valha no servidor
|
|
238
|
+
// (que injeta a conexão) e no cliente (onde a chamada vira stub RPC).
|
|
239
|
+
// ============================================================================
|
|
240
|
+
export async function ${actionName}(
|
|
241
|
+
payload: unknown,
|
|
242
|
+
db?: DatabaseClient,
|
|
243
|
+
session?: SessionContext
|
|
244
|
+
): Promise<${outputTypeName}> {
|
|
245
|
+
if (!db) {
|
|
246
|
+
return Err('NO_DATABASE');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Parsing JIT em memória
|
|
250
|
+
if (!Value.Check(${inputSchemaName}, payload)) {
|
|
251
|
+
return Err('INVALID_SCHEMA');
|
|
252
|
+
}
|
|
253
|
+
const input = payload as ${inputTypeName};
|
|
254
|
+
|
|
255
|
+
// Checagem de Duplicidade
|
|
256
|
+
const existing = await db.query<{ id: string }>(
|
|
257
|
+
\`SELECT id FROM ${tableName} WHERE email = $1\`,
|
|
258
|
+
[input.email]
|
|
259
|
+
);
|
|
260
|
+
if (existing.length > 0) {
|
|
261
|
+
return Err('DUPLICATE_EMAIL');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const generatedId = crypto.randomUUID();
|
|
265
|
+
const now = new Date().toISOString();
|
|
266
|
+
|
|
267
|
+
await db.query(
|
|
268
|
+
\`INSERT INTO ${tableName} (id, name, email) VALUES ($1, $2, $3)\`,
|
|
269
|
+
[generatedId, input.name, input.email]
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
return Ok({
|
|
273
|
+
id: generatedId,
|
|
274
|
+
name: input.name,
|
|
275
|
+
email: input.email,
|
|
276
|
+
createdAt: now
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ============================================================================
|
|
281
|
+
// 4. VISUALIZAÇÃO INTERATIVA DA UI (React)
|
|
282
|
+
// ============================================================================
|
|
283
|
+
export interface ${pascal}TriggerProps {
|
|
284
|
+
onSubmitAction?: (payload: unknown) => Promise<${outputTypeName}>;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function ${triggerName}({ onSubmitAction }: ${pascal}TriggerProps) {
|
|
288
|
+
const [feedback, setFeedback] = useState<string | null>(null);
|
|
289
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
290
|
+
|
|
291
|
+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
if (!onSubmitAction) return;
|
|
294
|
+
|
|
295
|
+
setIsSubmitting(true);
|
|
296
|
+
const formData = new FormData(e.currentTarget);
|
|
297
|
+
const payload = {
|
|
298
|
+
name: formData.get('name') as string,
|
|
299
|
+
email: formData.get('email') as string
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const res = await onSubmitAction(payload);
|
|
303
|
+
setIsSubmitting(false);
|
|
304
|
+
|
|
305
|
+
if (res.ok) {
|
|
306
|
+
setFeedback(\`Registro criado com sucesso! ID: \${res.value.id}\`);
|
|
307
|
+
} else {
|
|
308
|
+
setFeedback(\`Erro ao processar: \${res.error}\`);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<div className="synapse-${domain}-slice">
|
|
314
|
+
<h3 className="text-xl font-bold text-white mb-4">Novo Registro: ${pascal}</h3>
|
|
315
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
316
|
+
<div>
|
|
317
|
+
<label className="block text-xs font-mono text-slate-300 mb-1">Nome Completo:</label>
|
|
318
|
+
<input
|
|
319
|
+
type="text"
|
|
320
|
+
name="name"
|
|
321
|
+
required
|
|
322
|
+
minLength={2}
|
|
323
|
+
placeholder="Ex: João da Silva"
|
|
324
|
+
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-2.5 text-white text-sm focus:border-cyan-500 focus:outline-none"
|
|
325
|
+
/>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
<div>
|
|
329
|
+
<label className="block text-xs font-mono text-slate-300 mb-1">E-mail Corporativo:</label>
|
|
330
|
+
<input
|
|
331
|
+
type="email"
|
|
332
|
+
name="email"
|
|
333
|
+
required
|
|
334
|
+
placeholder="usuario@empresa.com"
|
|
335
|
+
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-2.5 text-white text-sm focus:border-cyan-500 focus:outline-none"
|
|
336
|
+
/>
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
<button
|
|
340
|
+
type="submit"
|
|
341
|
+
disabled={isSubmitting}
|
|
342
|
+
className="w-full py-2.5 px-4 bg-gradient-to-r from-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500 text-white font-medium text-sm rounded-lg shadow-lg shadow-cyan-600/20 transition-all disabled:opacity-50"
|
|
343
|
+
>
|
|
344
|
+
{isSubmitting ? 'Salvando...' : 'Salvar Registro'}
|
|
345
|
+
</button>
|
|
346
|
+
</form>
|
|
347
|
+
|
|
348
|
+
{feedback && (
|
|
349
|
+
<div className="mt-4 p-3 rounded-lg font-mono text-xs bg-slate-800 border border-slate-700 text-slate-300">
|
|
350
|
+
{feedback}
|
|
351
|
+
</div>
|
|
352
|
+
)}
|
|
353
|
+
</div>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ============================================================================
|
|
358
|
+
// 5. ORÁCULO DE AUTO-VERIFICAÇÃO PBT (Property-Based Testing)
|
|
359
|
+
// ============================================================================
|
|
360
|
+
export const sliceTests = {
|
|
361
|
+
description: 'Invariantes de ${sliceName}',
|
|
362
|
+
cases: [
|
|
363
|
+
{
|
|
364
|
+
name: 'nome curto ou e-mail inválido retornam INVALID_SCHEMA',
|
|
365
|
+
run: async () => {
|
|
366
|
+
fc.assert(
|
|
367
|
+
fc.asyncProperty(
|
|
368
|
+
fc.string({ minLength: 0, maxLength: 1 }),
|
|
369
|
+
fc.stringMatching(/^[a-z0-9]{1,10}$/),
|
|
370
|
+
async (shortName, invalidEmail) => {
|
|
371
|
+
const result = await ${actionName}(
|
|
372
|
+
{ name: shortName, email: invalidEmail },
|
|
373
|
+
new MockDatabaseClient()
|
|
374
|
+
);
|
|
375
|
+
return result.ok === false && result.error === 'INVALID_SCHEMA';
|
|
376
|
+
}
|
|
377
|
+
)
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
name: 'e-mail já cadastrado retorna DUPLICATE_EMAIL',
|
|
383
|
+
run: async () => {
|
|
384
|
+
const mockDbDuplicate = new MockDatabaseClient();
|
|
385
|
+
mockDbDuplicate.onQuery(/SELECT id FROM/, () => [{ id: 'existing-id-123' }]);
|
|
386
|
+
|
|
387
|
+
const dupResult = await ${actionName}(
|
|
388
|
+
{ name: 'Usuario Valido', email: 'teste@dominio.com' },
|
|
389
|
+
mockDbDuplicate
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
if (dupResult.ok || dupResult.error !== 'DUPLICATE_EMAIL') {
|
|
393
|
+
throw new Error(\`Invariante violada: esperava DUPLICATE_EMAIL, obteve \${JSON.stringify(dupResult)}\`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
name: 'sem conexão de banco retorna NO_DATABASE',
|
|
399
|
+
run: async () => {
|
|
400
|
+
const result = await ${actionName}({ name: 'Usuario Valido', email: 'teste@dominio.com' });
|
|
401
|
+
|
|
402
|
+
if (result.ok || result.error !== 'NO_DATABASE') {
|
|
403
|
+
throw new Error(\`Invariante violada: esperava NO_DATABASE, obteve \${JSON.stringify(result)}\`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
]
|
|
408
|
+
};
|
|
409
|
+
`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export type ScaffoldErrorCode = SlicesDirErrorCode | 'SLICE_EXISTS' | 'WRITE_FAILED';
|
|
413
|
+
|
|
414
|
+
export interface ScaffoldError {
|
|
415
|
+
code: ScaffoldErrorCode;
|
|
416
|
+
message: string;
|
|
417
|
+
candidates: string[];
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Resolves where a new slice should be written.
|
|
422
|
+
*
|
|
423
|
+
* A write target may legitimately not exist yet, so absence falls back to
|
|
424
|
+
* `<baseDir>/src/slices`. Ambiguity is always fatal — guessing the wrong app
|
|
425
|
+
* would scatter slices across the workspace.
|
|
426
|
+
*/
|
|
427
|
+
function resolveScaffoldTarget(baseDir: string): Result<string, ScaffoldError> {
|
|
428
|
+
const resolution = resolveSlicesDir(baseDir);
|
|
429
|
+
|
|
430
|
+
if (resolution.ok) {
|
|
431
|
+
return Ok(resolution.value.slicesDir);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (resolution.error.code === 'AMBIGUOUS_SLICES_DIR') {
|
|
435
|
+
return Err({
|
|
436
|
+
code: resolution.error.code,
|
|
437
|
+
message: resolution.error.message,
|
|
438
|
+
candidates: resolution.error.candidates
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return Ok(path.join(path.resolve(baseDir), 'src', 'slices'));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function scaffoldSlice(
|
|
446
|
+
domain: string,
|
|
447
|
+
sliceName: string,
|
|
448
|
+
baseDir: string = process.cwd(),
|
|
449
|
+
template: SliceTemplate = 'create',
|
|
450
|
+
fieldsSpec?: string
|
|
451
|
+
): Result<string, ScaffoldError> {
|
|
452
|
+
const target = resolveScaffoldTarget(baseDir);
|
|
453
|
+
|
|
454
|
+
if (!target.ok) {
|
|
455
|
+
return target;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const targetDir = path.join(target.value, domain);
|
|
459
|
+
const targetFile = path.join(targetDir, `${sliceName}.slice.tsx`);
|
|
460
|
+
|
|
461
|
+
if (fs.existsSync(targetFile)) {
|
|
462
|
+
return Err({
|
|
463
|
+
code: 'SLICE_EXISTS',
|
|
464
|
+
message: `A fatia '${targetFile}' já existe.`,
|
|
465
|
+
candidates: [targetFile]
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
471
|
+
const content =
|
|
472
|
+
template === 'create'
|
|
473
|
+
? generateSliceTemplate(domain, sliceName, fieldsSpec)
|
|
474
|
+
: generateOperationTemplate(domain, sliceName, template, fieldsSpec);
|
|
475
|
+
fs.writeFileSync(targetFile, content, 'utf-8');
|
|
476
|
+
} catch (err) {
|
|
477
|
+
return Err({
|
|
478
|
+
code: 'WRITE_FAILED',
|
|
479
|
+
message: err instanceof Error ? err.message : String(err),
|
|
480
|
+
candidates: [targetFile]
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return Ok(targetFile);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* The set an agent would otherwise invent: the four operations of one resource,
|
|
489
|
+
* each in its own file, all in the same shape as the rest of the framework.
|
|
490
|
+
*/
|
|
491
|
+
export function scaffoldCrud(
|
|
492
|
+
domain: string,
|
|
493
|
+
resource: string,
|
|
494
|
+
baseDir: string = process.cwd(),
|
|
495
|
+
fieldsSpec?: string
|
|
496
|
+
): Result<string[], ScaffoldError> {
|
|
497
|
+
const created: string[] = [];
|
|
498
|
+
|
|
499
|
+
for (const template of ['create', 'list', 'update', 'delete'] as const) {
|
|
500
|
+
const result = scaffoldSlice(domain, `${template}-${resource}`, baseDir, template, fieldsSpec);
|
|
501
|
+
|
|
502
|
+
if (!result.ok) {
|
|
503
|
+
return Err({
|
|
504
|
+
code: result.error.code,
|
|
505
|
+
message: `${template}: ${result.error.message}`,
|
|
506
|
+
candidates: result.error.candidates
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
created.push(result.value);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return Ok(created);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (import.meta.main) {
|
|
517
|
+
const domain = process.argv[2] || 'core';
|
|
518
|
+
const name = process.argv[3] || 'example-feature';
|
|
519
|
+
const created = scaffoldSlice(domain, name);
|
|
520
|
+
|
|
521
|
+
if (created.ok) {
|
|
522
|
+
process.stdout.write(`✅ [Scaffolder] Nova fatia gerada com sucesso: ${created.value}\n`);
|
|
523
|
+
process.exit(0);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
process.stderr.write(`❌ [Scaffolder] ${created.error.code}: ${created.error.message}\n`);
|
|
527
|
+
process.exit(1);
|
|
528
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - Schema DAG & Topological Migration Ordering
|
|
3
|
+
*
|
|
4
|
+
* Inspects slice DDL contracts for table definitions and foreign key references,
|
|
5
|
+
* building an acyclic dependency graph to guarantee parent tables
|
|
6
|
+
* are migrated before child tables referencing them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import * as ts from 'typescript';
|
|
12
|
+
import { extractSliceSchema } from './migration-runner';
|
|
13
|
+
|
|
14
|
+
export interface SliceSchemaMetadata {
|
|
15
|
+
filePath: string;
|
|
16
|
+
sliceName: string;
|
|
17
|
+
tablesCreated: string[];
|
|
18
|
+
tablesReferenced: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extracts table definitions and foreign key table references from a DDL string.
|
|
23
|
+
*/
|
|
24
|
+
export function parseTableDependencies(ddl: string): { created: string[]; referenced: string[] } {
|
|
25
|
+
const created: string[] = [];
|
|
26
|
+
const referenced: string[] = [];
|
|
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);
|
|
31
|
+
while (match !== null) {
|
|
32
|
+
const tableName = match[1].toLowerCase();
|
|
33
|
+
if (!created.includes(tableName)) {
|
|
34
|
+
created.push(tableName);
|
|
35
|
+
}
|
|
36
|
+
match = createRegex.exec(ddl);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Match REFERENCES <name>
|
|
40
|
+
const refRegex = /REFERENCES\s+["'`]?([a-zA-Z0-9_]+)["'`]?/gi;
|
|
41
|
+
let refMatch: RegExpExecArray | null = refRegex.exec(ddl);
|
|
42
|
+
while (refMatch !== null) {
|
|
43
|
+
const tableName = refMatch[1].toLowerCase();
|
|
44
|
+
if (!created.includes(tableName) && !referenced.includes(tableName)) {
|
|
45
|
+
referenced.push(tableName);
|
|
46
|
+
}
|
|
47
|
+
refMatch = refRegex.exec(ddl);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { created, referenced };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Sorts slice file paths topologically based on table dependencies so parent tables
|
|
55
|
+
* are created before dependent foreign-key child tables.
|
|
56
|
+
*/
|
|
57
|
+
export function orderSlicesByDag(sliceFiles: string[]): string[] {
|
|
58
|
+
const metadataMap = new Map<string, SliceSchemaMetadata>();
|
|
59
|
+
const tableToSlice = new Map<string, string>(); // tableName -> sliceFilePath
|
|
60
|
+
|
|
61
|
+
// Pass 1: Parse AST and metadata for all slices
|
|
62
|
+
for (const filePath of sliceFiles) {
|
|
63
|
+
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
|
64
|
+
const sourceFile = ts.createSourceFile(filePath, fileContent, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
65
|
+
const sliceName = path.basename(filePath, '.slice.tsx');
|
|
66
|
+
const ddl = extractSliceSchema(sourceFile);
|
|
67
|
+
|
|
68
|
+
if (!ddl) {
|
|
69
|
+
metadataMap.set(filePath, { filePath, sliceName, tablesCreated: [], tablesReferenced: [] });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { created, referenced } = parseTableDependencies(ddl);
|
|
74
|
+
metadataMap.set(filePath, { filePath, sliceName, tablesCreated: created, tablesReferenced: referenced });
|
|
75
|
+
|
|
76
|
+
for (const table of created) {
|
|
77
|
+
tableToSlice.set(table, filePath);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Pass 2: Build adjacency list for topological sorting
|
|
82
|
+
// Dependency: slice A depends on slice B (B must run before A)
|
|
83
|
+
const inDegree = new Map<string, number>();
|
|
84
|
+
const graph = new Map<string, string[]>(); // B -> list of A's that depend on B
|
|
85
|
+
|
|
86
|
+
for (const filePath of sliceFiles) {
|
|
87
|
+
inDegree.set(filePath, 0);
|
|
88
|
+
graph.set(filePath, []);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const [filePath, meta] of metadataMap) {
|
|
92
|
+
for (const refTable of meta.tablesReferenced) {
|
|
93
|
+
const parentFilePath = tableToSlice.get(refTable);
|
|
94
|
+
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);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Pass 3: Kahn's algorithm for topological sorting
|
|
103
|
+
const queue: string[] = [];
|
|
104
|
+
for (const [filePath, deg] of inDegree) {
|
|
105
|
+
if (deg === 0) {
|
|
106
|
+
queue.push(filePath);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const sorted: string[] = [];
|
|
111
|
+
while (queue.length > 0) {
|
|
112
|
+
const current = queue.shift();
|
|
113
|
+
if (!current) {
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
sorted.push(current);
|
|
117
|
+
|
|
118
|
+
for (const neighbor of graph.get(current) || []) {
|
|
119
|
+
const newDeg = (inDegree.get(neighbor) || 1) - 1;
|
|
120
|
+
inDegree.set(neighbor, newDeg);
|
|
121
|
+
if (newDeg === 0) {
|
|
122
|
+
queue.push(neighbor);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// If cycle or unvisited nodes remain, append remaining in original order
|
|
128
|
+
if (sorted.length < sliceFiles.length) {
|
|
129
|
+
for (const file of sliceFiles) {
|
|
130
|
+
if (!sorted.includes(file)) {
|
|
131
|
+
sorted.push(file);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return sorted;
|
|
137
|
+
}
|