@ismaelsoilet/synapsejs 1.4.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +108 -10
  2. package/bin/synapse.ts +263 -218
  3. package/package.json +1 -1
  4. package/src/client/components.tsx +26 -9
  5. package/src/client/outlet.tsx +40 -0
  6. package/src/client/sub-slice.tsx +45 -0
  7. package/src/client/use-websocket.ts +146 -0
  8. package/src/client.ts +12 -0
  9. package/src/compiler/client-bundler.ts +17 -7
  10. package/src/compiler/context-bench.ts +7 -1
  11. package/src/compiler/contract.ts +31 -4
  12. package/src/compiler/db-schema-generator.ts +39 -8
  13. package/src/compiler/fields-parser.ts +28 -1
  14. package/src/compiler/impact-analyzer.ts +31 -1
  15. package/src/compiler/migration-runner.ts +144 -11
  16. package/src/compiler/optional-coverage.ts +137 -0
  17. package/src/compiler/oracle-runner.ts +8 -3
  18. package/src/compiler/scaffolder.ts +174 -3
  19. package/src/compiler/schema-dag.ts +6 -3
  20. package/src/compiler/slice-discovery.ts +8 -1
  21. package/src/compiler/slice-splitter.ts +67 -6
  22. package/src/compiler/slice-templates.ts +10 -0
  23. package/src/compiler/standalone-builder.ts +16 -2
  24. package/src/core/action-context.ts +101 -8
  25. package/src/core/cache.ts +139 -0
  26. package/src/core/config.ts +124 -10
  27. package/src/core/database-client.ts +68 -6
  28. package/src/core/index.ts +5 -0
  29. package/src/core/path-guard.ts +105 -0
  30. package/src/core/postgres-client.ts +6 -0
  31. package/src/core/postgres-queue.ts +45 -7
  32. package/src/core/query-builder.ts +75 -60
  33. package/src/core/session-context.ts +1 -1
  34. package/src/core/session-token.ts +115 -8
  35. package/src/core/sqlite-client.ts +26 -1
  36. package/src/core/storage.ts +61 -24
  37. package/src/core/topics.ts +84 -0
  38. package/src/core/webhook-signature.ts +151 -0
  39. package/src/core/websocket.ts +30 -0
  40. package/src/index.ts +10 -2
  41. package/src/mcp/server.ts +78 -173
  42. package/src/mcp/tools.ts +238 -0
  43. package/src/project-status.ts +330 -0
  44. package/src/runtime/discovery-rules.ts +20 -1
  45. package/src/runtime/event-hub.ts +6 -1
  46. package/src/runtime/network-guard.ts +182 -0
  47. package/src/runtime/queue-engine.ts +102 -21
  48. package/src/runtime/rate-limiter.ts +24 -0
  49. package/src/runtime/server.ts +1380 -251
  50. package/src/runtime/uploads.ts +199 -19
  51. package/src/version.ts +11 -0
  52. package/templates/starter/.jev.json +1 -1
  53. package/templates/starter/tsconfig.json +1 -0
@@ -0,0 +1,40 @@
1
+ import type React from 'react';
2
+ import { createContext, type ReactNode, useContext } from 'react';
3
+
4
+ export interface SliceOutletContextValue {
5
+ content?: ReactNode;
6
+ outlets?: Record<string, ReactNode>;
7
+ }
8
+
9
+ const SliceOutletContext = createContext<SliceOutletContextValue>({});
10
+
11
+ export interface SliceOutletProviderProps {
12
+ content?: ReactNode;
13
+ outlets?: Record<string, ReactNode>;
14
+ children: ReactNode;
15
+ }
16
+
17
+ export function SliceOutletProvider({ content, outlets = {}, children }: SliceOutletProviderProps) {
18
+ return <SliceOutletContext.Provider value={{ content, outlets }}>{children}</SliceOutletContext.Provider>;
19
+ }
20
+
21
+ export interface SliceOutletProps {
22
+ name?: string;
23
+ fallback?: ReactNode;
24
+ children?: ReactNode;
25
+ }
26
+
27
+ /**
28
+ * <SliceOutlet /> renders nested content within Root or Domain Layouts,
29
+ * enabling parallel sub-panels and composable nested views.
30
+ */
31
+ export function SliceOutlet({ name, fallback = null, children }: SliceOutletProps): React.ReactElement | null {
32
+ const ctx = useContext(SliceOutletContext);
33
+ if (children) {
34
+ return <>{children}</>;
35
+ }
36
+ if (name && ctx.outlets) {
37
+ return <>{ctx.outlets[name] ?? fallback}</>;
38
+ }
39
+ return <>{ctx.content ?? fallback}</>;
40
+ }
@@ -0,0 +1,45 @@
1
+ import React, { type ComponentType, type ReactElement, useMemo } from 'react';
2
+ import { rpcCall } from '../core/rpc-client';
3
+ import { SynapseProvider } from './context';
4
+
5
+ export interface SubSliceProps<TProps extends Record<string, unknown> = Record<string, unknown>> {
6
+ domain: string;
7
+ name: string;
8
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic component props boundary
9
+ component?: ComponentType<any>;
10
+ props?: TProps;
11
+ actionPropNames?: string[];
12
+ children?: (injectedProps: Record<string, unknown>) => ReactElement;
13
+ }
14
+
15
+ /**
16
+ * <SubSlice /> dynamically embeds a sub-slice component into any layout or parent view,
17
+ * automatically wiring its transparent RPC action endpoint without violating Locality of Behavior (N = 1).
18
+ */
19
+ export function SubSlice<TProps extends Record<string, unknown> = Record<string, unknown>>({
20
+ domain,
21
+ name,
22
+ component: Component,
23
+ props = {} as TProps,
24
+ actionPropNames = ['action', 'onSubmitAction'],
25
+ children
26
+ }: SubSliceProps<TProps>): ReactElement {
27
+ const rpcPath = `/_synapse/rpc/${domain}/${name}`;
28
+
29
+ const boundActions = useMemo(() => {
30
+ const actions: Record<string, (payload: unknown) => Promise<unknown>> = {};
31
+ for (const actionName of actionPropNames) {
32
+ actions[actionName] = (payload: unknown) => rpcCall(rpcPath, payload);
33
+ }
34
+ return actions;
35
+ }, [rpcPath, actionPropNames]);
36
+
37
+ const mergedProps = {
38
+ ...props,
39
+ ...boundActions
40
+ };
41
+
42
+ const content = children ? children(mergedProps) : Component ? React.createElement(Component, mergedProps) : null;
43
+
44
+ return <SynapseProvider props={mergedProps}>{content}</SynapseProvider>;
45
+ }
@@ -0,0 +1,146 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ export interface UseWebSocketOptions<TServerMsg = unknown> {
4
+ enabled?: boolean;
5
+ reconnect?: boolean;
6
+ reconnectIntervalMs?: number;
7
+ onOpen?: () => void;
8
+ onMessage?: (message: TServerMsg) => void;
9
+ onClose?: (event: CloseEvent) => void;
10
+ onError?: (event: Event) => void;
11
+ }
12
+
13
+ export interface UseWebSocketResult<TServerMsg = unknown, TClientMsg = unknown> {
14
+ isConnected: boolean;
15
+ lastMessage: TServerMsg | null;
16
+ send: (data: TClientMsg | string) => void;
17
+ close: () => void;
18
+ error: Error | null;
19
+ }
20
+
21
+ /**
22
+ * Hook for bidirectional WebSocket communication with SynapseJS slices.
23
+ */
24
+ export function useWebSocket<TServerMsg = unknown, TClientMsg = unknown>(
25
+ pathOrSlice: string,
26
+ options: UseWebSocketOptions<TServerMsg> = {}
27
+ ): UseWebSocketResult<TServerMsg, TClientMsg> {
28
+ const { enabled = true, reconnect = true, reconnectIntervalMs = 3000 } = options;
29
+
30
+ const [isConnected, setIsConnected] = useState<boolean>(false);
31
+ const [lastMessage, setLastMessage] = useState<TServerMsg | null>(null);
32
+ const [error, setError] = useState<Error | null>(null);
33
+
34
+ const socketRef = useRef<WebSocket | null>(null);
35
+ const optionsRef = useRef(options);
36
+ optionsRef.current = options;
37
+
38
+ const resolveUrl = useCallback((path: string): string => {
39
+ if (typeof window === 'undefined') return '';
40
+ if (path.startsWith('ws://') || path.startsWith('wss://')) return path;
41
+
42
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
43
+ const host = window.location.host;
44
+
45
+ if (path.startsWith('/')) {
46
+ return `${protocol}//${host}${path}`;
47
+ }
48
+ // Formato <domain>/<name>
49
+ return `${protocol}//${host}/_synapse/ws/${path}`;
50
+ }, []);
51
+
52
+ useEffect(() => {
53
+ if (!enabled || typeof window === 'undefined') return;
54
+
55
+ let isMounted = true;
56
+ let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
57
+
58
+ const connect = () => {
59
+ try {
60
+ const url = resolveUrl(pathOrSlice);
61
+ if (!url) return;
62
+
63
+ const ws = new WebSocket(url);
64
+ socketRef.current = ws;
65
+
66
+ ws.onopen = () => {
67
+ if (!isMounted) return;
68
+ setIsConnected(true);
69
+ setError(null);
70
+ optionsRef.current.onOpen?.();
71
+ };
72
+
73
+ ws.onmessage = (event) => {
74
+ if (!isMounted) return;
75
+ try {
76
+ const data = JSON.parse(event.data) as TServerMsg;
77
+ setLastMessage(data);
78
+ optionsRef.current.onMessage?.(data);
79
+ } catch {
80
+ const raw = event.data as unknown as TServerMsg;
81
+ setLastMessage(raw);
82
+ optionsRef.current.onMessage?.(raw);
83
+ }
84
+ };
85
+
86
+ ws.onerror = (e) => {
87
+ if (!isMounted) return;
88
+ const err = new Error(`Erro na conexão WebSocket para "${pathOrSlice}"`);
89
+ setError(err);
90
+ optionsRef.current.onError?.(e);
91
+ };
92
+
93
+ ws.onclose = (e) => {
94
+ if (!isMounted) return;
95
+ setIsConnected(false);
96
+ optionsRef.current.onClose?.(e);
97
+
98
+ if (reconnect && isMounted && !e.wasClean) {
99
+ reconnectTimeout = setTimeout(connect, reconnectIntervalMs);
100
+ }
101
+ };
102
+ } catch (err) {
103
+ if (!isMounted) return;
104
+ setError(err instanceof Error ? err : new Error(String(err)));
105
+ }
106
+ };
107
+
108
+ connect();
109
+
110
+ return () => {
111
+ isMounted = false;
112
+ if (reconnectTimeout) clearTimeout(reconnectTimeout);
113
+ if (socketRef.current) {
114
+ socketRef.current.close();
115
+ socketRef.current = null;
116
+ }
117
+ setIsConnected(false);
118
+ };
119
+ }, [pathOrSlice, enabled, reconnect, reconnectIntervalMs, resolveUrl]);
120
+
121
+ const send = useCallback(
122
+ (data: TClientMsg | string) => {
123
+ if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
124
+ const payload = typeof data === 'string' ? data : JSON.stringify(data);
125
+ socketRef.current.send(payload);
126
+ } else {
127
+ console.warn(`[useWebSocket] Tentativa de envio em socket desconectado (${pathOrSlice})`);
128
+ }
129
+ },
130
+ [pathOrSlice]
131
+ );
132
+
133
+ const close = useCallback(() => {
134
+ if (socketRef.current) {
135
+ socketRef.current.close();
136
+ }
137
+ }, []);
138
+
139
+ return {
140
+ isConnected,
141
+ lastMessage,
142
+ send,
143
+ close,
144
+ error
145
+ };
146
+ }
package/src/client.ts CHANGED
@@ -53,6 +53,18 @@ export {
53
53
  type TranslateFn,
54
54
  type TranslationDictionary
55
55
  } from './client/i18n';
56
+ export {
57
+ SliceOutlet,
58
+ type SliceOutletProps,
59
+ SliceOutletProvider,
60
+ type SliceOutletProviderProps
61
+ } from './client/outlet';
62
+ export { SubSlice, type SubSliceProps } from './client/sub-slice';
63
+ export {
64
+ type UseWebSocketOptions,
65
+ type UseWebSocketResult,
66
+ useWebSocket
67
+ } from './client/use-websocket';
56
68
  export type { Result } from './core/machine-types';
57
69
  export { type RpcTransportError, rpcCall, rpcTransportFailure } from './core/rpc-client';
58
70
  export type { SessionContext } from './core/session-context';
@@ -61,11 +61,16 @@ export function clientManifestPath(baseDir: string): string {
61
61
  return path.join(baseDir, '.synapse', 'client', 'manifest.json');
62
62
  }
63
63
 
64
- export async function buildVendorBundle(baseDir: string): Promise<Result<BuiltBundle, BuildError>> {
64
+ export async function buildVendorBundle(
65
+ baseDir: string,
66
+ vendorPackages: string[] = []
67
+ ): Promise<Result<BuiltBundle, BuildError>> {
65
68
  const vendorEntryDir = path.join(baseDir, '.synapse/client-entry');
66
69
  fs.mkdirSync(vendorEntryDir, { recursive: true });
67
70
  const vendorEntryPath = path.join(vendorEntryDir, '_vendor.ts');
68
71
 
72
+ const extraExports = (vendorPackages || []).map((pkg) => `export * from '${pkg}';`).join('\n');
73
+
69
74
  const vendorEntryContent = [
70
75
  `import React from 'react';`,
71
76
  `import * as ReactDOM from 'react-dom';`,
@@ -73,6 +78,7 @@ export async function buildVendorBundle(baseDir: string): Promise<Result<BuiltBu
73
78
  `import * as SynapseClient from 'synapsejs/client';`,
74
79
  `import * as jsxRuntime from 'react/jsx-runtime';`,
75
80
  `import * as jsxDevRuntime from 'react/jsx-dev-runtime';`,
81
+ ...(extraExports ? [extraExports] : []),
76
82
  ``,
77
83
  `export default React;`,
78
84
  `export * from 'react';`,
@@ -209,7 +215,8 @@ export function componentExportOf(module: Record<string, unknown>): string | und
209
215
 
210
216
  export async function buildClientBundle(
211
217
  target: BundleTarget,
212
- baseDir: string
218
+ baseDir: string,
219
+ options?: { vendorPackages?: string[] }
213
220
  ): Promise<Result<BuiltBundle, BuildError>> {
214
221
  // Uma fatia que não carrega não pode derrubar o build inteiro: ela vira FAIL com o motivo.
215
222
  let sliceModule: Record<string, unknown>;
@@ -311,7 +318,7 @@ export async function buildClientBundle(
311
318
  if (vendorSplit) {
312
319
  const vendorFile = path.join(baseDir, '.synapse/client', VENDOR_BUNDLE_NAME);
313
320
  if (!fs.existsSync(vendorFile)) {
314
- await buildVendorBundle(baseDir);
321
+ await buildVendorBundle(baseDir, options?.vendorPackages);
315
322
  }
316
323
  }
317
324
 
@@ -334,7 +341,8 @@ export async function buildClientBundle(
334
341
  'react-dom',
335
342
  'react-dom/client',
336
343
  'synapsejs/client',
337
- '@ismaelsoilet/synapsejs/client'
344
+ '@ismaelsoilet/synapsejs/client',
345
+ ...(options?.vendorPackages || [])
338
346
  ]
339
347
  : [],
340
348
  define: { 'process.env.NODE_ENV': production ? '"production"' : '"development"' }
@@ -421,7 +429,8 @@ export interface BuildReportEntry {
421
429
  * component and fails to produce a bundle is a broken build.
422
430
  */
423
431
  export async function buildAllClientBundles(
424
- appRoot: string
432
+ appRoot: string,
433
+ options?: { vendorPackages?: string[] }
425
434
  ): Promise<Result<{ entries: BuildReportEntry[]; manifestFile: string }, SlicesDirError>> {
426
435
  const resolution = resolveSlicesDir(appRoot);
427
436
 
@@ -433,7 +442,7 @@ export async function buildAllClientBundles(
433
442
  const entries: BuildReportEntry[] = [];
434
443
  const built: BuiltBundle[] = [];
435
444
 
436
- const vendorRes = await buildVendorBundle(appRoot);
445
+ const vendorRes = await buildVendorBundle(appRoot, options?.vendorPackages);
437
446
  if (vendorRes.ok) {
438
447
  built.push(vendorRes.value);
439
448
  entries.push({ slice: '_vendor', status: 'PASS', url: vendorRes.value.url, bytes: vendorRes.value.bytes });
@@ -446,7 +455,8 @@ export async function buildAllClientBundles(
446
455
 
447
456
  const bundle = await buildClientBundle(
448
457
  { key, name, domain, filePath: file, rpcPath: `/_synapse/rpc/${domain}/${name}` },
449
- appRoot
458
+ appRoot,
459
+ options
450
460
  );
451
461
 
452
462
  if (bundle.ok) {
@@ -174,7 +174,7 @@ export function measureAppSurface(appRoot: string): AppSurface {
174
174
 
175
175
  export function renderSurfaceReport(surfaces: AppSurface[]): string {
176
176
  const lines = [
177
- '| app | feature | arquivos (app) | tokens (app, est.) | arquivos (total) | tokens (total, est.) |',
177
+ '| app | feature | arquivos (app) | tokens (app, est.) | arquivos (total, não comparável) | tokens (total, est., não comparável) |',
178
178
  '|---|---|---|---|---|---|'
179
179
  ];
180
180
 
@@ -187,5 +187,11 @@ export function renderSurfaceReport(surfaces: AppSurface[]): string {
187
187
  }
188
188
  }
189
189
 
190
+ lines.push(
191
+ '',
192
+ 'As colunas `app` são as comparáveis (os arquivos da própria funcionalidade em cada aplicação).',
193
+ 'As colunas `total` cobrem conjuntos de arquivos diferentes por repositório e não são comparáveis'
194
+ );
195
+
190
196
  return lines.join('\n');
191
197
  }
@@ -11,6 +11,8 @@
11
11
  * discovery and the splitter use, so the description cannot drift from behavior.
12
12
  */
13
13
 
14
+ import { PATH_SEGMENT_PATTERN } from '../core/path-guard';
15
+ import { REALTIME_REJECTIONS } from '../core/topics';
14
16
  import {
15
17
  ACTION_SUFFIX,
16
18
  COMPONENT_SUFFIXES,
@@ -18,6 +20,7 @@ import {
18
20
  SLICE_DDL_EXPORT,
19
21
  SLICE_ORACLE_EXPORT
20
22
  } from '../runtime/discovery-rules';
23
+ import { SCAFFOLD_FAILURE_CODES } from './scaffolder';
21
24
  import { SLICE_EXTENSION } from './slice-discovery';
22
25
  import { SLICE_TEMPLATES } from './slice-templates';
23
26
 
@@ -49,6 +52,7 @@ export interface MachineContract {
49
52
  signed: string;
50
53
  anonymous: string;
51
54
  login: string;
55
+ cookieReality: string;
52
56
  };
53
57
  addressing: {
54
58
  page: string;
@@ -71,6 +75,16 @@ export interface MachineContract {
71
75
  available: string[];
72
76
  notes: string;
73
77
  };
78
+ scaffolding: {
79
+ command: string;
80
+ containment: string;
81
+ failureCodes: Record<string, string>;
82
+ };
83
+ realtime: {
84
+ transports: string[];
85
+ rule: string;
86
+ rejections: Record<string, number>;
87
+ };
74
88
  gates: Array<{ command: string; proves: string }>;
75
89
  }
76
90
 
@@ -187,16 +201,18 @@ export function machineContract(): MachineContract {
187
201
  sessions: {
188
202
  headers: ['Authorization: Bearer <token>', 'x-user-id', 'x-user-roles'],
189
203
  signed:
190
- 'With SYNAPSE_SESSION_SECRET set, a bearer token must be a signed session (signSessionToken) and its claims win: role headers are ignored.',
204
+ 'With SYNAPSE_SESSION_SECRET set, a bearer token must be a signed session (signSessionToken) and its claims win: role headers are ignored, and the tenant is taken exclusively from the verified claims (a signed token without a tenant claim yields a session without a tenant). Revocation is consulted on every request, in-process first and then against the persisted blacklist. Without the secret, identity headers are honoured only under the explicit development opt-in SYNAPSE_DEV_HEADERS=true (or a development/test runtime mode); every other state resolves to an anonymous session.',
191
205
  anonymous: 'A request with none of them is anonymous: requireAuth returns Err("UNAUTHORIZED").',
192
206
  login:
193
- 'There is no built-in login screen and no server-side session state. A login slice (synapse new-slice auth login --template=login) verifies the password with Bun.password.verify, signs the session with signSessionToken and returns the token; the browser stores it with storeSession(token, roles) and logs out with clearSession(). After that the token travels as Authorization: Bearer and the server authorizes from the signature.'
207
+ 'There is no built-in login screen and no server-side session state. A login slice (synapse new-slice auth login --template=login) verifies the password with Bun.password.verify, signs the session with signSessionToken and returns the token; the browser stores it with storeSession(token, roles) and logs out with clearSession(). After that the token travels as Authorization: Bearer and the server authorizes from the signature.',
208
+ cookieReality:
209
+ 'A session cookie written by browser script (storeSession) is written through document.cookie, so it is readable by any script in the origin — HttpOnly cannot be set from script, by construction. A session that must be unreachable by script is the one the server emits through ctx.setCookie, which carries HttpOnly, Secure and SameSite=Lax unless the caller states otherwise.'
194
210
  },
195
211
  addressing: {
196
212
  page: 'GET /<domain>/<name>',
197
213
  rpc: 'POST /_synapse/rpc/<domain>/<name> with Content-Type: application/json',
198
214
  upload:
199
- 'POST /_synapse/files/<domain>/<name>?name=arquivo.pdf sends raw bytes (multipart is not needed and not parsed). It requires a session, refuses a name containing a directory, and stops reading past SYNAPSE_MAX_UPLOAD_BYTES (5 MiB by default). It answers { ok, path, bytes } with a path relative to the app root; what the bytes mean is the slice contract to decide.',
215
+ "POST /_synapse/files/<domain>/<name>?name=arquivo.pdf sends raw bytes or multipart/form-data. It requires a session, refuses a name containing a directory, enforces a byte ceiling while the bytes arrive on every branch (SYNAPSE_MAX_UPLOAD_BYTES, 5 MiB by default), and accepts a file only when its declared extension is within the configured policy (SYNAPSE_UPLOAD_ALLOWED_TYPES) and matches the bytes actually received — so a client cannot choose the extension its file is stored under. It answers { ok, path, bytes } with a path relative to the app root. The framework does not serve the upload directory: serving it is the application's responsibility.",
200
216
  ambiguity:
201
217
  'A bare name resolves only while it is unique across domains; otherwise the dispatcher answers 409 listing the candidates.'
202
218
  },
@@ -211,7 +227,7 @@ export function machineContract(): MachineContract {
211
227
  location: 'src/shared/<name>.ts',
212
228
  rule: 'A slice never imports another slice — the splitter fails with SLICE_IMPORTS_SLICE, transitively, and says so. What two features must do together lives in src/shared/, receives the DatabaseClient by parameter and is called from both slices. A shared module may throw; the action that calls it converts the failure into Err, so the no-throw contract of an action survives a transaction.',
213
229
  atomicity:
214
- 'db.transaction(fn) is the boundary: the statements inside commit together or roll back together. The mock database does not roll back, so atomicity must be proven against a real engine (SQLite in the suite, PostgreSQL via test:postgres) — never with the mock.'
230
+ 'db.transaction(fn) is the boundary: the statements inside commit together or roll back together. The mock database rolls back its in-memory store, which makes it usable for control-flow assertions, but atomicity against a real engine (SQLite in the suite, PostgreSQL via test:postgres) is what proves the engine agrees.'
215
231
  },
216
232
  templates: {
217
233
  command: 'synapse new-slice <domain> <name> --template=<template>',
@@ -219,6 +235,17 @@ export function machineContract(): MachineContract {
219
235
  notes:
220
236
  'create is the default. crud generates create-<name>, list-<name>, update-<name> and delete-<name> for one resource. list paginates (LIMIT/OFFSET plus COUNT) with a parameterized filter; update is partial and builds its SET clause from a column allowlist, never from the payload; delete checks existence first. Every generated file already carries an oracle.'
221
237
  },
238
+ realtime: {
239
+ transports: ['GET /_synapse/sse/<topic> (event stream)', 'GET /_synapse/ws/<domain>/<name> (WebSocket upgrade)'],
240
+ rule: 'A topic must be declared with defineTopic({ name, owner, readRoles?, tenantId?, public? }) before anyone subscribes or publishes. Subscribing to an undeclared topic is refused; a topic declared only for another tenant is refused without disclosing that it exists; publishing into a topic the slice does not own is rejected and notifies nobody. Topics are namespaced per tenant. The WebSocket upgrade is refused unless the request carries a present origin matching SYNAPSE_ALLOWED_ORIGINS (or `*`), with a loopback-origin carve-out for local development. Both transports share one rate limiter and a per-client cap on concurrent connections.',
241
+ rejections: { ...REALTIME_REJECTIONS }
242
+ },
243
+ scaffolding: {
244
+ command:
245
+ 'synapse new-slice <domain> <name> [--template=<template>] [--fields=<grammar>] · synapse new-shared <name> · synapse new <project>',
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
+ failureCodes: { ...SCAFFOLD_FAILURE_CODES }
248
+ },
222
249
  gates: [
223
250
  {
224
251
  command: 'synapse check [arquivo]',
@@ -129,20 +129,50 @@ function splitColumnDefinitions(body: string): string[] {
129
129
  return defs;
130
130
  }
131
131
 
132
+ /** Index of the `)` matching the `(` at `openIndex`, or -1. Linear in the input. */
133
+ function findMatchingParen(text: string, openIndex: number): number {
134
+ let depth = 0;
135
+
136
+ for (let index = openIndex; index < text.length; index++) {
137
+ const char = text[index];
138
+
139
+ if (char === '(') {
140
+ depth++;
141
+ } else if (char === ')') {
142
+ depth--;
143
+
144
+ if (depth === 0) {
145
+ return index;
146
+ }
147
+ }
148
+ }
149
+
150
+ return -1;
151
+ }
152
+
132
153
  /**
133
154
  * Parses DDL string to extract all table and column definitions.
155
+ *
156
+ * The header is matched first and the column body is delimited by scanning for the
157
+ * matching parenthesis. The previous single-pattern form (`([\s\S]*?)\)(?:\s*;|\s*$)`)
158
+ * backtracked quadratically on a large DDL.
134
159
  */
135
160
  export function parseDdlToCatalog(ddl: string, sourceSlice?: string): TableDefinition[] {
136
161
  const tables: TableDefinition[] = [];
162
+ const createTableHeader = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?([a-zA-Z0-9_]+)["'`]?\s*\(/gi;
163
+
164
+ let header: RegExpExecArray | null = createTableHeader.exec(ddl);
137
165
 
138
- // Match CREATE TABLE [IF NOT EXISTS] <name> (<columns>)
139
- const createTableRegex =
140
- /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?([a-zA-Z0-9_]+)["'`]?\s*\(([\s\S]*?)\)(?:\s*;|\s*$)/gi;
166
+ while (header !== null) {
167
+ const openParen = header.index + header[0].length - 1;
168
+ const closeParen = findMatchingParen(ddl, openParen);
169
+
170
+ if (closeParen === -1) {
171
+ break;
172
+ }
141
173
 
142
- let match: RegExpExecArray | null = createTableRegex.exec(ddl);
143
- while (match !== null) {
144
- const tableName = match[1].toLowerCase();
145
- const body = match[2];
174
+ const tableName = header[1].toLowerCase();
175
+ const body = ddl.slice(openParen + 1, closeParen);
146
176
  const rawDefs = splitColumnDefinitions(body);
147
177
  const columns: ColumnDefinition[] = [];
148
178
 
@@ -202,7 +232,8 @@ export function parseDdlToCatalog(ddl: string, sourceSlice?: string): TableDefin
202
232
  sourceSlice
203
233
  });
204
234
 
205
- match = createTableRegex.exec(ddl);
235
+ createTableHeader.lastIndex = closeParen + 1;
236
+ header = createTableHeader.exec(ddl);
206
237
  }
207
238
 
208
239
  return tables;
@@ -6,6 +6,8 @@
6
6
  * DataForm/DataTable specs, and Fast-Check PBT test arbitraries.
7
7
  */
8
8
 
9
+ import { Err, Ok, type Result } from '../core/machine-types';
10
+
9
11
  export type FieldType = 'string' | 'number' | 'integer' | 'boolean' | 'enum' | 'date' | 'timestamp' | 'json';
10
12
 
11
13
  export interface ParsedField {
@@ -17,6 +19,32 @@ export interface ParsedField {
17
19
  label: string;
18
20
  }
19
21
 
22
+ /**
23
+ * Field and enum-value names are interpolated into generated DDL and generated
24
+ * TypeScript, so they must be plain identifiers before any code generation runs.
25
+ */
26
+ const FIELD_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
27
+
28
+ /**
29
+ * Validates every field name (and enum value) in a parsed field list.
30
+ * Returns the first offending name as the error value.
31
+ */
32
+ export function validateFieldNames(fields: ParsedField[]): Result<ParsedField[], string> {
33
+ for (const field of fields) {
34
+ if (!FIELD_NAME_PATTERN.test(field.name)) {
35
+ return Err(field.name);
36
+ }
37
+
38
+ for (const value of field.enumValues ?? []) {
39
+ if (!FIELD_NAME_PATTERN.test(value)) {
40
+ return Err(value);
41
+ }
42
+ }
43
+ }
44
+
45
+ return Ok(fields);
46
+ }
47
+
20
48
  function fieldToLabel(name: string): string {
21
49
  return name
22
50
  .replace(/_/g, ' ')
@@ -173,7 +201,6 @@ export function generateSqlColumns(fields: ParsedField[]): string {
173
201
  case 'json':
174
202
  case 'string':
175
203
  case 'enum':
176
- default:
177
204
  sqlType = 'TEXT';
178
205
  break;
179
206
  }
@@ -24,7 +24,10 @@ export interface ImpactedSlice {
24
24
  }
25
25
 
26
26
  export interface ImpactAnalysisReport {
27
- status: 'PASS';
27
+ status: 'PASS' | 'FAIL';
28
+ /** Present only when the target could not be resolved in this project. */
29
+ code?: 'TARGET_NOT_RESOLVED';
30
+ message?: string;
28
31
  operation: 'IMPACT_ANALYSIS';
29
32
  target: string;
30
33
  targetType: 'slice' | 'table' | 'shared_module';
@@ -156,6 +159,8 @@ export function analyzeImpact(target: string, rootDir: string = process.cwd()):
156
159
 
157
160
  const impactedMap = new Map<string, ImpactedSlice>();
158
161
 
162
+ let resolved = false;
163
+
159
164
  if (targetType === 'slice') {
160
165
  const targetSlice = sliceDataList.find(
161
166
  (s) =>
@@ -164,6 +169,8 @@ export function analyzeImpact(target: string, rootDir: string = process.cwd()):
164
169
  s.filePath.endsWith(`/${cleanTarget}`)
165
170
  );
166
171
 
172
+ resolved = Boolean(targetSlice);
173
+
167
174
  if (targetSlice) {
168
175
  impactedMap.set(targetSlice.sliceName, {
169
176
  sliceName: targetSlice.sliceName,
@@ -208,6 +215,10 @@ export function analyzeImpact(target: string, rootDir: string = process.cwd()):
208
215
  } else if (targetType === 'shared_module') {
209
216
  const sharedName = path.basename(cleanTarget, path.extname(cleanTarget));
210
217
 
218
+ resolved =
219
+ fs.existsSync(path.resolve(rootDir, cleanTarget)) ||
220
+ fs.existsSync(path.join(rootDir, 'src', 'shared', `${sharedName}.ts`));
221
+
211
222
  for (const slice of sliceDataList) {
212
223
  if (slice.sharedImports.includes(sharedName)) {
213
224
  impactedMap.set(slice.sliceName, {
@@ -223,6 +234,9 @@ export function analyzeImpact(target: string, rootDir: string = process.cwd()):
223
234
  // targetType === 'table'
224
235
  const tableName = cleanTarget.toLowerCase();
225
236
 
237
+ // A table exists in this project when some committed slice declares it.
238
+ resolved = sliceDataList.some((slice) => slice.tablesCreated.includes(tableName));
239
+
226
240
  for (const slice of sliceDataList) {
227
241
  if (slice.tablesCreated.includes(tableName)) {
228
242
  impactedMap.set(slice.sliceName, {
@@ -267,6 +281,22 @@ export function analyzeImpact(target: string, rootDir: string = process.cwd()):
267
281
  recommendedCommands.push('synapse test');
268
282
  }
269
283
 
284
+ if (!resolved) {
285
+ // An unresolvable target is a failure, not a successful empty result. Nothing
286
+ // was verified, so reporting PASS would be the same defect discovery refuses.
287
+ return {
288
+ status: 'FAIL',
289
+ code: 'TARGET_NOT_RESOLVED',
290
+ message: `Alvo '${cleanTarget}' não existe no projeto: nenhuma fatia, módulo compartilhado ou tabela corresponde.`,
291
+ operation: 'IMPACT_ANALYSIS',
292
+ target: cleanTarget,
293
+ targetType,
294
+ totalImpacted: 0,
295
+ impactedSlices: [],
296
+ recommendedCommands: []
297
+ };
298
+ }
299
+
270
300
  return {
271
301
  status: 'PASS',
272
302
  operation: 'IMPACT_ANALYSIS',