@ismaelsoilet/synapsejs 1.9.0 → 1.11.1

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 (38) hide show
  1. package/README.md +4 -3
  2. package/bin/synapse.ts +74 -2
  3. package/package.json +2 -2
  4. package/src/client/components.tsx +459 -1
  5. package/src/client/context.tsx +23 -3
  6. package/src/client/error-boundary.tsx +59 -0
  7. package/src/client/hooks.ts +100 -1
  8. package/src/client/toast.tsx +219 -0
  9. package/src/client/upload.ts +153 -0
  10. package/src/client.ts +37 -2
  11. package/src/compiler/client-bundler.ts +94 -2
  12. package/src/compiler/context-bench.ts +2 -0
  13. package/src/compiler/contract.ts +1 -1
  14. package/src/compiler/oracle-runner.ts +10 -1
  15. package/src/compiler/scaffolder.ts +113 -4
  16. package/src/compiler/slice-splitter.ts +21 -3
  17. package/src/compiler/slice-templates.ts +234 -10
  18. package/src/compiler/typescript-api.ts +33 -0
  19. package/src/core/action-context.ts +50 -1
  20. package/src/core/cron-parser.ts +167 -0
  21. package/src/core/database-client.ts +35 -0
  22. package/src/core/index.ts +2 -0
  23. package/src/core/jobs.ts +37 -0
  24. package/src/core/oauth-client.ts +322 -0
  25. package/src/core/postgres-client.ts +37 -0
  26. package/src/core/postgres-queue.ts +51 -2
  27. package/src/core/query-builder.ts +166 -34
  28. package/src/core/session-context.ts +45 -4
  29. package/src/core/session-token.ts +16 -1
  30. package/src/core/sqlite-client.ts +20 -0
  31. package/src/mcp/server.ts +257 -1
  32. package/src/mcp/tools.ts +92 -1
  33. package/src/project-status.ts +103 -0
  34. package/src/runtime/client-entry.ts +11 -5
  35. package/src/runtime/queue-engine.ts +120 -5
  36. package/src/runtime/security-headers.ts +35 -0
  37. package/src/runtime/server.ts +300 -58
  38. package/templates/starter/package.json +2 -2
@@ -17,6 +17,7 @@ import * as path from 'path';
17
17
  import { Err, Ok, type Result } from '../core/machine-types';
18
18
  import { clientEntrySource } from '../runtime/client-entry';
19
19
  import { isComponent } from '../runtime/discovery-rules';
20
+ import { SYNAPSE_VERSION } from '../version';
20
21
  import { findSliceFiles, resolveSlicesDir, type SlicesDirError } from './slice-discovery';
21
22
  import { artifactDirectory, splitSlice, verifySplit, writeSplitArtifacts } from './slice-splitter';
22
23
 
@@ -61,6 +62,29 @@ export function clientManifestPath(baseDir: string): string {
61
62
  return path.join(baseDir, '.synapse', 'client', 'manifest.json');
62
63
  }
63
64
 
65
+ export async function buildCssBundle(baseDir: string): Promise<Result<{ path: string; bytes: number }, BuildError>> {
66
+ const clientDir = path.join(baseDir, '.synapse/client');
67
+ fs.mkdirSync(clientDir, { recursive: true });
68
+ const outPath = path.join(clientDir, 'synapse.css');
69
+
70
+ const sourceCssCandidates = [
71
+ path.join(baseDir, 'public/synapse.css'),
72
+ path.join(baseDir, 'src/styles/synapse.css'),
73
+ path.join(baseDir, 'src/styles/main.css')
74
+ ];
75
+
76
+ for (const src of sourceCssCandidates) {
77
+ if (fs.existsSync(src)) {
78
+ const content = fs.readFileSync(src, 'utf-8');
79
+ fs.writeFileSync(outPath, content, 'utf-8');
80
+ return Ok({ path: outPath, bytes: Buffer.byteLength(content, 'utf-8') });
81
+ }
82
+ }
83
+
84
+ // No source means no stylesheet: the framework does not impose a theme.
85
+ return Ok({ path: outPath, bytes: 0 });
86
+ }
87
+
64
88
  export async function buildVendorBundle(
65
89
  baseDir: string,
66
90
  vendorPackages: string[] = []
@@ -72,6 +96,7 @@ export async function buildVendorBundle(
72
96
  const extraExports = (vendorPackages || []).map((pkg) => `export * from '${pkg}';`).join('\n');
73
97
 
74
98
  const vendorEntryContent = [
99
+ `// SynapseJS Client Vendor Entry - Framework v${SYNAPSE_VERSION}`,
75
100
  `import React from 'react';`,
76
101
  `import * as ReactDOM from 'react-dom';`,
77
102
  `import * as ReactDOMClient from 'react-dom/client';`,
@@ -114,6 +139,30 @@ export async function buildVendorBundle(
114
139
  ` Children,`,
115
140
  ` startTransition`,
116
141
  `} = (React as unknown as Record<string, unknown>);`,
142
+ `const {`,
143
+ ` Badge,`,
144
+ ` Button,`,
145
+ ` Card,`,
146
+ ` DataForm,`,
147
+ ` DataTable,`,
148
+ ` Drawer,`,
149
+ ` DropdownItem,`,
150
+ ` DropdownMenu,`,
151
+ ` Modal,`,
152
+ ` Pagination,`,
153
+ ` Tab,`,
154
+ ` TabList,`,
155
+ ` TabPanel,`,
156
+ ` Tabs,`,
157
+ ` ToastProvider,`,
158
+ ` useToast,`,
159
+ ` SynapseProvider,`,
160
+ ` useAction,`,
161
+ ` useLoaderData,`,
162
+ ` useSubscription,`,
163
+ ` useWebSocket,`,
164
+ ` rpcCall`,
165
+ `} = (SynapseClient as unknown as Record<string, unknown>);`,
117
166
  `export {`,
118
167
  ` ReactDOM,`,
119
168
  ` ReactDOMClient,`,
@@ -146,7 +195,29 @@ export async function buildVendorBundle(
146
195
  ` memo,`,
147
196
  ` forwardRef,`,
148
197
  ` Children,`,
149
- ` startTransition`,
198
+ ` startTransition,`,
199
+ ` Badge,`,
200
+ ` Button,`,
201
+ ` Card,`,
202
+ ` DataForm,`,
203
+ ` DataTable,`,
204
+ ` Drawer,`,
205
+ ` DropdownItem,`,
206
+ ` DropdownMenu,`,
207
+ ` Modal,`,
208
+ ` Pagination,`,
209
+ ` Tab,`,
210
+ ` TabList,`,
211
+ ` TabPanel,`,
212
+ ` Tabs,`,
213
+ ` ToastProvider,`,
214
+ ` useToast,`,
215
+ ` SynapseProvider,`,
216
+ ` useAction,`,
217
+ ` useLoaderData,`,
218
+ ` useSubscription,`,
219
+ ` useWebSocket,`,
220
+ ` rpcCall`,
150
221
  `};`,
151
222
  ``
152
223
  ].join('\n');
@@ -160,7 +231,28 @@ export async function buildVendorBundle(
160
231
  fs.mkdirSync(buildDir, { recursive: true });
161
232
  const vendorFile = path.join(buildDir, VENDOR_BUNDLE_NAME);
162
233
 
163
- if (fs.existsSync(vendorFile) && existingEntry === vendorEntryContent) {
234
+ let isVendorStale = false;
235
+ if (fs.existsSync(vendorFile)) {
236
+ const vendorMtime = fs.statSync(vendorFile).mtimeMs;
237
+ try {
238
+ const localClientPath = path.resolve(__dirname, '../client.ts');
239
+ if (fs.existsSync(localClientPath) && fs.statSync(localClientPath).mtimeMs > vendorMtime) {
240
+ isVendorStale = true;
241
+ }
242
+ const localComponentsPath = path.resolve(__dirname, '../client/components.tsx');
243
+ if (fs.existsSync(localComponentsPath) && fs.statSync(localComponentsPath).mtimeMs > vendorMtime) {
244
+ isVendorStale = true;
245
+ }
246
+ const pkgJsonPath = path.join(baseDir, 'package.json');
247
+ if (fs.existsSync(pkgJsonPath) && fs.statSync(pkgJsonPath).mtimeMs > vendorMtime) {
248
+ isVendorStale = true;
249
+ }
250
+ } catch {
251
+ // ignore stat errors
252
+ }
253
+ }
254
+
255
+ if (fs.existsSync(vendorFile) && existingEntry === vendorEntryContent && !isVendorStale) {
164
256
  return Ok({
165
257
  sliceKey: '_vendor',
166
258
  url: VENDOR_BUNDLE_URL,
@@ -22,6 +22,7 @@
22
22
  import * as fs from 'fs';
23
23
  import * as path from 'path';
24
24
  import * as ts from 'typescript';
25
+ import { assertCompilerApi } from './typescript-api';
25
26
 
26
27
  export const TOKENS_PER_BYTE_ESTIMATE = 0.25;
27
28
 
@@ -149,6 +150,7 @@ interface BenchConfig {
149
150
  }
150
151
 
151
152
  export function measureAppSurface(appRoot: string): AppSurface {
153
+ assertCompilerApi();
152
154
  const configPath = path.join(appRoot, 'bench.config.json');
153
155
  if (!fs.existsSync(configPath)) {
154
156
  throw new Error(`bench.config.json ausente em ${appRoot}`);
@@ -242,7 +242,7 @@ export function machineContract(): MachineContract {
242
242
  },
243
243
  scaffolding: {
244
244
  command:
245
- 'synapse new-slice <domain> <name> [--template=<template>] [--fields=<grammar>] · synapse new-shared <name> · synapse new <project>',
245
+ 'synapse new-slice <domain> <name> [--template=<template>] [--fields=<grammar>] · synapse new-shared <name> · synapse new-domain <domain> [--fields=<grammar>] · synapse new <project>',
246
246
  containment: `The domain, the slice name, the shared-module name and the new-project directory are each one safe path segment (pattern ${PATH_SEGMENT_PATTERN}), and before anything is written the resolved target is asserted to stay inside the directory the operation owns. A field name in --fields must be a plain identifier before it is interpolated into the generated DDL or TypeScript. Every containment failure is printed as JSON on standard output with a non-zero exit code.`,
247
247
  failureCodes: { ...SCAFFOLD_FAILURE_CODES }
248
248
  },
@@ -156,6 +156,8 @@ export function oracleWrapperSource(importPath: string, sliceName: string): stri
156
156
  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
+ /** Optional target slice name or pattern to run tests exclusively for */
160
+ targetSlice?: string;
159
161
  /** Enable Jev System One semantic test triage on failures. Default: true */
160
162
  /**
161
163
  * Opt-in (default: disabled). The triage consults an external model provider when
@@ -196,7 +198,14 @@ export async function runSliceOracles(
196
198
  }
197
199
 
198
200
  const slicesDir = resolution.value.slicesDir;
199
- const sliceFiles = findSliceFiles(slicesDir);
201
+ const allSliceFiles = findSliceFiles(slicesDir);
202
+ const targetSlice = options.targetSlice;
203
+ const sliceFiles = targetSlice
204
+ ? allSliceFiles.filter((f) => {
205
+ const name = path.basename(f, '.slice.tsx');
206
+ return name === targetSlice || f.includes(targetSlice);
207
+ })
208
+ : allSliceFiles;
200
209
  const oraclesDir = path.join(root, '.synapse', ORACLE_DIR_NAME);
201
210
  const reportPath = path.join(oraclesDir, 'report.xml');
202
211
 
@@ -58,10 +58,9 @@ import {
58
58
  Ok,
59
59
  Err,
60
60
  MockDatabaseClient,
61
- type SessionContext,
62
- DataForm,
63
- Card
61
+ type SessionContext
64
62
  } from 'synapsejs';
63
+ import { DataForm, type FormField, Card } from 'synapsejs/client';
65
64
 
66
65
  // ============================================================================
67
66
  // 1. CONTRATO DE ENTRADA JIT (TypeBox)
@@ -128,7 +127,7 @@ export interface ${pascal}TriggerProps {
128
127
  onSubmitAction?: (payload: unknown) => Promise<${outputTypeName}>;
129
128
  }
130
129
 
131
- const FORM_FIELDS = ${formFields};
130
+ const FORM_FIELDS: FormField[] = ${formFields};
132
131
 
133
132
  export function ${triggerName}({ onSubmitAction }: ${pascal}TriggerProps) {
134
133
  const [feedback, setFeedback] = useState<string | null>(null);
@@ -426,6 +425,7 @@ export type ScaffoldErrorCode =
426
425
  | SlicesDirErrorCode
427
426
  | 'SLICE_EXISTS'
428
427
  | 'WRITE_FAILED'
428
+ | 'PARTIAL_WRITE'
429
429
  | 'INVALID_PATH_SEGMENT'
430
430
  | 'INVALID_FIELD_NAME';
431
431
 
@@ -440,6 +440,8 @@ export const SCAFFOLD_FAILURE_CODES: Record<ScaffoldErrorCode, string> = {
440
440
  'more than one application owns a slices directory; set SYNAPSE_ROOT or run inside the target application',
441
441
  SLICE_EXISTS: 'the target file already exists',
442
442
  WRITE_FAILED: 'the filesystem refused the write',
443
+ PARTIAL_WRITE:
444
+ 'a domain scaffolding operation failed and one or more files could not be removed; candidates lists what remains',
443
445
  INVALID_PATH_SEGMENT:
444
446
  'a domain, slice name, shared-module name or project directory is not a single safe path segment',
445
447
  INVALID_FIELD_NAME: 'a field name or enum value in --fields is not a plain identifier'
@@ -684,6 +686,113 @@ export async function executeShared${pascalName}(
684
686
  }
685
687
  }
686
688
 
689
+ /**
690
+ * Scaffolds an entire modular domain directory with domain layout (_layout.tsx),
691
+ * an initial listing slice (listar-<domain>.slice.tsx), and shared module (src/shared/<domain>.ts).
692
+ */
693
+ export function scaffoldDomain(
694
+ domain: string,
695
+ baseDir: string = process.cwd(),
696
+ fieldsSpec?: string
697
+ ): Result<string[], ScaffoldError> {
698
+ const domainRejection = segmentRejection('domain', domain, 'o domínio');
699
+ if (domainRejection) return Err(domainRejection);
700
+
701
+ const target = resolveScaffoldTarget(baseDir);
702
+ if (!target.ok) return Err(target.error);
703
+
704
+ const domainDir = path.join(target.value, domain);
705
+ const layoutFile = path.join(domainDir, '_layout.tsx');
706
+ const domainDirExisted = fs.existsSync(domainDir);
707
+ const createdFiles: string[] = [];
708
+
709
+ const fail = (error: ScaffoldError): Result<string[], ScaffoldError> => {
710
+ const cleanupFailures: string[] = [];
711
+
712
+ for (const file of [...createdFiles].reverse()) {
713
+ try {
714
+ fs.rmSync(file, { force: true });
715
+ } catch {
716
+ cleanupFailures.push(file);
717
+ }
718
+ }
719
+
720
+ if (!domainDirExisted) {
721
+ try {
722
+ fs.rmdirSync(domainDir);
723
+ } catch {
724
+ if (fs.existsSync(domainDir)) {
725
+ cleanupFailures.push(domainDir);
726
+ }
727
+ }
728
+ }
729
+
730
+ if (cleanupFailures.length > 0) {
731
+ return Err({
732
+ code: 'PARTIAL_WRITE',
733
+ message: `${error.code}: ${error.message}; não foi possível remover: ${cleanupFailures.join(', ')}`,
734
+ candidates: cleanupFailures
735
+ });
736
+ }
737
+
738
+ return Err(error);
739
+ };
740
+
741
+ try {
742
+ fs.mkdirSync(domainDir, { recursive: true });
743
+
744
+ // 1. Domain layout if not exists
745
+ if (!fs.existsSync(layoutFile)) {
746
+ const pascalDomain = toPascalCase(domain);
747
+ const layoutContent = `import React, { type ReactNode } from 'react';
748
+
749
+ export default function ${pascalDomain}DomainLayout({ children }: { children: ReactNode; url?: string; session?: any }) {
750
+ return (
751
+ <div className="flex flex-col flex-1 w-full min-h-full">
752
+ <header className="border-b border-slate-800 bg-slate-900/60 backdrop-blur px-6 py-4 flex items-center justify-between">
753
+ <div>
754
+ <div className="text-xs uppercase tracking-wider text-cyan-400 font-semibold">Domínio</div>
755
+ <h2 className="text-xl font-bold text-slate-100 capitalize">${domain}</h2>
756
+ </div>
757
+ <nav className="flex gap-4 text-sm font-medium">
758
+ <a href="/${domain}/listar-${domain}" className="text-slate-300 hover:text-cyan-400 transition-colors">Listagem</a>
759
+ </nav>
760
+ </header>
761
+ <main className="flex-1 p-6 max-w-7xl w-full mx-auto">
762
+ {children}
763
+ </main>
764
+ </div>
765
+ );
766
+ }
767
+ `;
768
+ fs.writeFileSync(layoutFile, layoutContent, 'utf-8');
769
+ createdFiles.push(layoutFile);
770
+ }
771
+
772
+ // 2. Initial List Slice
773
+ const listSliceRes = scaffoldSlice(domain, `listar-${domain}`, baseDir, 'list', fieldsSpec);
774
+ if (!listSliceRes.ok) {
775
+ return fail(listSliceRes.error);
776
+ }
777
+ createdFiles.push(listSliceRes.value);
778
+
779
+ // 3. Shared module
780
+ const sharedRes = scaffoldShared(domain, baseDir);
781
+ if (!sharedRes.ok) {
782
+ return fail(sharedRes.error);
783
+ }
784
+ createdFiles.push(sharedRes.value);
785
+ } catch (err) {
786
+ return fail({
787
+ code: 'WRITE_FAILED',
788
+ message: err instanceof Error ? err.message : String(err),
789
+ candidates: [domainDir]
790
+ });
791
+ }
792
+
793
+ return Ok(createdFiles);
794
+ }
795
+
687
796
  if (import.meta.main) {
688
797
  const domain = process.argv[2] || 'core';
689
798
  const name = process.argv[3] || 'example-feature';
@@ -40,6 +40,7 @@ export interface SplitArtifact {
40
40
  export interface SplitResult {
41
41
  sliceName: string;
42
42
  artifacts: SplitArtifact[];
43
+ lineCount?: number;
43
44
  }
44
45
 
45
46
  export type SplitErrorCode =
@@ -86,7 +87,11 @@ export const SERVER_ONLY_PATTERNS: Array<{ label: string; pattern: RegExp }> = [
86
87
  { label: 'CREATE TABLE statement', pattern: /\bCREATE\s+TABLE\b/i },
87
88
  { label: 'ALTER TABLE statement', pattern: /\bALTER\s+TABLE\b/i },
88
89
  { label: 'DROP TABLE statement', pattern: /\bDROP\s+TABLE\b/i },
89
- { label: 'database call', pattern: /\bdb\s*\.\s*(?:query|findMany|findOne|insert|update|delete|sql|sqlOne)\b/ },
90
+ {
91
+ label: 'database call',
92
+ pattern:
93
+ /\bdb\s*\.\s*(?:query|queryOne|findMany|findOne|insert|update|delete|sql|sqlOne|aggregate|count|exists|transaction)\b/
94
+ },
90
95
  { label: 'sliceSchema symbol', pattern: /\bsliceSchema\b/ },
91
96
  { label: 'test runner import', pattern: /from\s+['"]bun:test['"]/ },
92
97
  { label: 'process.env access', pattern: /\bprocess\s*(?:\.\s*env|\[\s*['"]env['"]\s*\])/ },
@@ -818,7 +823,8 @@ export function splitSlice(sliceFilePath: string, baseDir: string = process.cwd(
818
823
  )
819
824
  });
820
825
 
821
- return Ok({ sliceName, artifacts });
826
+ const lineCount = sourceFile.text.split('\n').length;
827
+ return Ok({ sliceName, artifacts, lineCount });
822
828
  }
823
829
 
824
830
  export function writeSplitArtifacts(result: SplitResult, outDir: string): string[] {
@@ -899,8 +905,20 @@ export function verifySplit(result: SplitResult, outDir: string): SplitVerificat
899
905
  }
900
906
  }
901
907
 
908
+ if (result.lineCount && result.lineCount > 500) {
909
+ diagnostics.push({
910
+ file: `${result.sliceName}.slice.tsx`,
911
+ line: 1,
912
+ column: 1,
913
+ code: 'WARN_LARGE_SLICE',
914
+ message: `Fatia excede 500 linhas (${result.lineCount} linhas). Para manter alta densidade sem sobrecarregar agentes de IA e humanos, considere decompor este domínio em sub-fatias de intenção de negócio com <SubSlice> e src/shared/ (consulte docs/arquitetura-sistemas-grandes.pt-BR.md).`
915
+ });
916
+ }
917
+
918
+ const hasErrors = diagnostics.some((d) => !d.code.startsWith('WARN_'));
919
+
902
920
  return {
903
- status: diagnostics.length === 0 && leaks.length === 0 ? 'PASS' : 'FAIL',
921
+ status: !hasErrors && leaks.length === 0 ? 'PASS' : 'FAIL',
904
922
  diagnostics,
905
923
  leaks
906
924
  };