@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,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
+ }