@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,192 @@
1
+ /**
2
+ * SynapseJS - Uploads
3
+ *
4
+ * The RPC endpoint speaks JSON on purpose — that is what closes CSRF by
5
+ * construction — so files need their own door. This one requires a session,
6
+ * refuses a name that tries to escape the upload directory, and stops reading the
7
+ * moment the body passes the limit instead of buffering it first.
8
+ *
9
+ * The endpoint stores bytes and returns a relative path; what those bytes mean is
10
+ * the slice's business, decided by its own contract.
11
+ */
12
+
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+ import { Err, Ok, type Result } from '../core/machine-types';
16
+ import type { SessionContext } from '../core/session-context';
17
+
18
+ export const DEFAULT_MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
19
+
20
+ /** No separators, no `..`, no leading dash or dot, and a sane length. */
21
+ const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/;
22
+
23
+ export type UploadErrorCode = 'INVALID_NAME' | 'UNAUTHORIZED' | 'TOO_LARGE' | 'EMPTY_BODY' | 'WRITE_FAILED';
24
+
25
+ export function maxUploadBytes(raw: string | undefined = process.env.SYNAPSE_MAX_UPLOAD_BYTES): number {
26
+ const parsed = Number(raw);
27
+
28
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : DEFAULT_MAX_UPLOAD_BYTES;
29
+ }
30
+
31
+ /**
32
+ * Accepts a plain file name or refuses it — it never silently rewrites one, so a
33
+ * caller sending `../../etc/passwd` gets an error instead of a surprise file.
34
+ */
35
+ export function safeUploadName(raw: string | null): Result<string, 'INVALID_NAME'> {
36
+ if (!raw) {
37
+ return Err('INVALID_NAME');
38
+ }
39
+
40
+ const trimmed = raw.trim();
41
+
42
+ if (path.basename(trimmed) !== trimmed || !NAME_PATTERN.test(trimmed)) {
43
+ return Err('INVALID_NAME');
44
+ }
45
+
46
+ return Ok(trimmed);
47
+ }
48
+
49
+ export interface UploadOutcome {
50
+ status: number;
51
+ body: Record<string, unknown>;
52
+ }
53
+
54
+ /** Reads at most `maxBytes`, cancelling the stream instead of buffering a bomb. */
55
+ export async function readBodyWithinLimit(
56
+ body: ReadableStream<Uint8Array> | null,
57
+ maxBytes: number
58
+ ): Promise<Result<Uint8Array, 'TOO_LARGE'>> {
59
+ if (!body) {
60
+ return Ok(new Uint8Array());
61
+ }
62
+
63
+ const reader = body.getReader();
64
+ const chunks: Uint8Array[] = [];
65
+ let total = 0;
66
+
67
+ for (;;) {
68
+ const { done, value } = await reader.read();
69
+
70
+ if (done) {
71
+ break;
72
+ }
73
+
74
+ if (!value) {
75
+ continue;
76
+ }
77
+
78
+ total += value.byteLength;
79
+
80
+ if (total > maxBytes) {
81
+ await reader.cancel();
82
+ return Err('TOO_LARGE');
83
+ }
84
+
85
+ chunks.push(value);
86
+ }
87
+
88
+ const merged = new Uint8Array(total);
89
+ let offset = 0;
90
+
91
+ for (const chunk of chunks) {
92
+ merged.set(chunk, offset);
93
+ offset += chunk.byteLength;
94
+ }
95
+
96
+ return Ok(merged);
97
+ }
98
+
99
+ export async function saveUpload(
100
+ request: Request,
101
+ options: { baseDir: string; domain: string; session: SessionContext; maxBytes?: number }
102
+ ): Promise<UploadOutcome> {
103
+ if (!options.session.isAuthenticated) {
104
+ return { status: 401, body: { ok: false, error: 'UNAUTHORIZED' } };
105
+ }
106
+
107
+ const maxBytes = options.maxBytes ?? maxUploadBytes();
108
+
109
+ const declared = Number(request.headers.get('content-length') ?? '');
110
+ if (Number.isFinite(declared) && declared > maxBytes) {
111
+ return { status: 413, body: { ok: false, error: 'TOO_LARGE', maxBytes } };
112
+ }
113
+
114
+ const contentType = request.headers.get('content-type') || '';
115
+ let fileBytes: Uint8Array;
116
+ let targetFileName: string;
117
+
118
+ if (contentType.includes('multipart/form-data')) {
119
+ let formData: FormData;
120
+ try {
121
+ formData = await request.formData();
122
+ } catch {
123
+ return { status: 400, body: { ok: false, error: 'INVALID_MULTIPART_BODY' } };
124
+ }
125
+ const file = formData.get('file');
126
+ if (!file || typeof file === 'string') {
127
+ return {
128
+ status: 422,
129
+ body: { ok: false, error: 'NO_FILE_IN_FORM_DATA', hint: 'envie o arquivo no campo "file"' }
130
+ };
131
+ }
132
+ const uploadedFile = file as File;
133
+ if (uploadedFile.size > maxBytes) {
134
+ return { status: 413, body: { ok: false, error: 'TOO_LARGE', maxBytes } };
135
+ }
136
+ if (uploadedFile.size === 0) {
137
+ return { status: 422, body: { ok: false, error: 'EMPTY_BODY' } };
138
+ }
139
+
140
+ const rawName = uploadedFile.name || new URL(request.url).searchParams.get('name');
141
+ const name = safeUploadName(rawName);
142
+ if (!name.ok) {
143
+ return {
144
+ status: 400,
145
+ body: { ok: false, error: 'INVALID_NAME', hint: 'nome do arquivo inválido' }
146
+ };
147
+ }
148
+ targetFileName = name.value;
149
+ fileBytes = new Uint8Array(await uploadedFile.arrayBuffer());
150
+ } else {
151
+ const name = safeUploadName(new URL(request.url).searchParams.get('name'));
152
+
153
+ if (!name.ok) {
154
+ return {
155
+ status: 400,
156
+ body: { ok: false, error: 'INVALID_NAME', hint: 'passe ?name=arquivo.pdf, sem diretórios e sem ".."' }
157
+ };
158
+ }
159
+
160
+ const body = await readBodyWithinLimit(request.body, maxBytes);
161
+ if (!body.ok) {
162
+ return { status: 413, body: { ok: false, error: 'TOO_LARGE', maxBytes } };
163
+ }
164
+
165
+ if (body.value.byteLength === 0) {
166
+ return { status: 422, body: { ok: false, error: 'EMPTY_BODY' } };
167
+ }
168
+
169
+ targetFileName = name.value;
170
+ fileBytes = body.value;
171
+ }
172
+
173
+ const directory = path.join(options.baseDir, '.synapse', 'uploads', options.domain);
174
+ const fileName = `${crypto.randomUUID().slice(0, 8)}-${targetFileName}`;
175
+ const target = path.join(directory, fileName);
176
+
177
+ try {
178
+ fs.mkdirSync(directory, { recursive: true });
179
+ fs.writeFileSync(target, fileBytes);
180
+ } catch (err) {
181
+ return { status: 500, body: { ok: false, error: 'WRITE_FAILED', message: (err as Error).message } };
182
+ }
183
+
184
+ return {
185
+ status: 200,
186
+ body: {
187
+ ok: true,
188
+ path: path.relative(options.baseDir, target).split(path.sep).join('/'),
189
+ bytes: fileBytes.byteLength
190
+ }
191
+ };
192
+ }
@@ -0,0 +1,7 @@
1
+ node_modules
2
+ .git
3
+ .synapse/dist
4
+ .synapse/oracles
5
+ .synapse/synapse.sqlite*
6
+ .synapse/queue.sqlite*
7
+ *.log
@@ -0,0 +1,40 @@
1
+ # ==============================================================================
2
+ # SynapseJS Production Dockerfile (Starter Template)
3
+ # Ultra-lightweight (~90MB), non-root execution, native health check
4
+ # ==============================================================================
5
+
6
+ FROM oven/bun:alpine AS builder
7
+ WORKDIR /app
8
+
9
+ # Install dependencies with lockfile caching
10
+ COPY package.json bun.lock* ./
11
+ RUN bun install --frozen-lockfile
12
+
13
+ # Copy application source code
14
+ COPY . .
15
+
16
+ # Pre-build client bundles and standalone distribution
17
+ RUN bun run build:standalone
18
+
19
+ # ==============================================================================
20
+ # Production Runner Stage
21
+ # ==============================================================================
22
+ FROM oven/bun:alpine AS runner
23
+ WORKDIR /app
24
+
25
+ ENV NODE_ENV=production \
26
+ PORT=3000 \
27
+ SYNAPSE_LOG=json
28
+
29
+ # Copy standalone distribution and production dependencies from builder
30
+ COPY --from=builder /app/.synapse/standalone ./
31
+ COPY --from=builder /app/node_modules ./node_modules
32
+
33
+ USER bun
34
+
35
+ EXPOSE 3000
36
+
37
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
38
+ CMD bun -e "fetch('http://localhost:' + (process.env.PORT || 3000) + '/_synapse/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
39
+
40
+ CMD ["bun", "run", "server.ts"]
@@ -0,0 +1,40 @@
1
+ # SynapseJS Starter App ⚡
2
+
3
+ Aplicação construída com **SynapseJS** — framework fullstack para Bun onde uma feature inteira
4
+ (contrato, DDL, server action, UI e oráculo) vive num único arquivo `*.slice.tsx`.
5
+
6
+ Requisito: **Bun >= 1.2**.
7
+
8
+ ## Comandos rápidos
9
+
10
+ ```bash
11
+ # 1. Iniciar servidor com SSR e auto-migração
12
+ bun run dev
13
+
14
+ # 2. Criar uma nova fatia vertical com 1 comando
15
+ bun run new-slice <domain> <name>
16
+ # Exemplo: bun run new-slice users register-user
17
+
18
+ # 3. Rodar os oráculos da fatia
19
+ bun run test
20
+
21
+ # 4. Checar tipos
22
+ bun run check
23
+
24
+ # 5. Iniciar servidor MCP local (JSON-RPC sobre stdio)
25
+ bun run mcp
26
+
27
+ # 6. Particionar as fatias em shared/server/client e rodar os dois gates
28
+ bun run split
29
+ ```
30
+
31
+ ## RBAC no browser
32
+
33
+ As Server Actions recebem `SessionContext`. Para exercitar uma action protegida a partir da UI,
34
+ defina os cookies que o shell SSR repassa como headers:
35
+
36
+ ```js
37
+ document.cookie = 'synapse_token=demo; synapse_roles=support';
38
+ ```
39
+
40
+ Sem credencial a action responde `Err('UNAUTHORIZED')` — o RBAC é aplicado de verdade, não decorativo.
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ .synapse/
3
+ .codebase/
4
+ dist/
5
+ *.log
6
+ .DS_Store
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "starter-app",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "engines": {
6
+ "bun": ">=1.2.0"
7
+ },
8
+ "scripts": {
9
+ "dev": "synapse dev",
10
+ "check": "synapse check",
11
+ "migrate": "synapse migrate",
12
+ "mcp": "synapse mcp",
13
+ "skeleton": "synapse skeleton",
14
+ "test": "synapse test",
15
+ "new-slice": "synapse new-slice",
16
+ "split": "synapse split",
17
+ "build": "synapse build",
18
+ "build:standalone": "synapse build --standalone",
19
+ "start": "synapse start"
20
+ },
21
+ "dependencies": {
22
+ "@ismaelsoilet/synapsejs": "^1.1.0",
23
+ "@sinclair/typebox": "^0.34.40",
24
+ "fast-check": "^3.23.2",
25
+ "react": "^19.0.0",
26
+ "react-dom": "^19.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/bun": "^1.4.2",
30
+ "@types/react": "^19.0.10",
31
+ "@types/react-dom": "^19.0.4",
32
+ "typescript": "^5.8.2"
33
+ }
34
+ }
@@ -0,0 +1,172 @@
1
+ import type React from 'react';
2
+ import { useState } from 'react';
3
+ import {
4
+ type DatabaseClient,
5
+ Err,
6
+ fc,
7
+ MockDatabaseClient,
8
+ Ok,
9
+ type Result,
10
+ type SessionContext,
11
+ type Static,
12
+ Type,
13
+ Value
14
+ } from 'synapsejs';
15
+
16
+ // ============================================================================
17
+ // 1. CONTRATO DE ENTRADA JIT (TypeBox)
18
+ // ============================================================================
19
+ export const HelloWorldInputSchema = Type.Object({
20
+ name: Type.String({ minLength: 2, maxLength: 50 }),
21
+ message: Type.Optional(Type.String({ maxLength: 200 }))
22
+ });
23
+ export type HelloWorldInput = Static<typeof HelloWorldInputSchema>;
24
+
25
+ // DDL DECLARATIVO DA FATIA (Auto-migrado via AST pelo Synapse)
26
+ export const sliceSchema = `
27
+ CREATE TABLE IF NOT EXISTS greetings (
28
+ id TEXT PRIMARY KEY,
29
+ name TEXT NOT NULL,
30
+ message TEXT,
31
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
32
+ );
33
+ `;
34
+
35
+ // ============================================================================
36
+ // 2. MODELAGEM ESTRITA DE DOMÍNIO (Result<T, E>)
37
+ // ============================================================================
38
+ export type HelloWorldOutput = Result<
39
+ { greetingId: string; greeting: string; createdAt: string },
40
+ 'INVALID_SCHEMA' | 'NO_DATABASE' | 'PERSISTENCE_FAILED'
41
+ >;
42
+
43
+ // ============================================================================
44
+ // 3. EXECUÇÃO DE SERVIDOR PURA (SERVER ACTION)
45
+ // ============================================================================
46
+ export async function helloWorldAction(
47
+ payload: unknown,
48
+ db?: DatabaseClient,
49
+ _session?: SessionContext
50
+ ): Promise<HelloWorldOutput> {
51
+ if (!db) {
52
+ return Err('NO_DATABASE');
53
+ }
54
+
55
+ if (!Value.Check(HelloWorldInputSchema, payload)) {
56
+ return Err('INVALID_SCHEMA');
57
+ }
58
+ const input = payload as HelloWorldInput;
59
+ const greetingId = crypto.randomUUID();
60
+ const greeting = `Olá, ${input.name}! Bem-vindo ao SynapseJS.`;
61
+
62
+ await db.query(`INSERT INTO greetings (id, name, message) VALUES ($1, $2, $3)`, [
63
+ greetingId,
64
+ input.name,
65
+ input.message || 'Boas-vindas'
66
+ ]);
67
+
68
+ return Ok({
69
+ greetingId,
70
+ greeting,
71
+ createdAt: new Date().toISOString()
72
+ });
73
+ }
74
+
75
+ // ============================================================================
76
+ // 4. COMPONENTE UI REACT
77
+ // ============================================================================
78
+ export interface HelloWorldViewProps {
79
+ defaultName?: string;
80
+ onSubmitAction?: (payload: unknown) => Promise<HelloWorldOutput>;
81
+ }
82
+
83
+ export function HelloWorldView({ defaultName = 'Desenvolvedor', onSubmitAction }: HelloWorldViewProps) {
84
+ const [feedback, setFeedback] = useState<string | null>(null);
85
+
86
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
87
+ e.preventDefault();
88
+ if (!onSubmitAction) return;
89
+
90
+ const form = e.currentTarget;
91
+ const formData = new FormData(form);
92
+ const payload = {
93
+ name: formData.get('name') as string,
94
+ message: (formData.get('message') as string) || undefined
95
+ };
96
+
97
+ const res = await onSubmitAction(payload);
98
+ if (res.ok) {
99
+ setFeedback(res.value.greeting);
100
+ } else {
101
+ setFeedback(`Erro: ${res.error}`);
102
+ }
103
+ };
104
+
105
+ return (
106
+ <div className="max-w-md mx-auto p-6 bg-slate-900 border border-slate-800 rounded-xl text-slate-100">
107
+ <h2 className="text-xl font-bold mb-4 text-cyan-400">⚡ SynapseJS Starter Slice</h2>
108
+ <form onSubmit={handleSubmit} className="space-y-4">
109
+ <div>
110
+ <label htmlFor="hello-name" className="block text-xs font-mono text-slate-400 mb-1">
111
+ Seu Nome:
112
+ </label>
113
+ <input
114
+ id="hello-name"
115
+ name="name"
116
+ defaultValue={defaultName}
117
+ required
118
+ className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-sm text-white"
119
+ />
120
+ </div>
121
+ <div>
122
+ <label htmlFor="hello-message" className="block text-xs font-mono text-slate-400 mb-1">
123
+ Mensagem (Opcional):
124
+ </label>
125
+ <input
126
+ id="hello-message"
127
+ name="message"
128
+ placeholder="Primeira fatia no SynapseJS..."
129
+ className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-sm text-white"
130
+ />
131
+ </div>
132
+ <button
133
+ type="submit"
134
+ className="w-full py-2 bg-cyan-600 hover:bg-cyan-500 rounded text-white font-medium text-sm transition-colors"
135
+ >
136
+ Executar Ação RPC
137
+ </button>
138
+ </form>
139
+ {feedback && <div className="mt-4 p-3 bg-slate-800 rounded text-xs font-mono text-cyan-300">{feedback}</div>}
140
+ </div>
141
+ );
142
+ }
143
+
144
+ // ============================================================================
145
+ // 5. ORÁCULO DE AUTO-VERIFICAÇÃO PBT (Property-Based Testing)
146
+ // ============================================================================
147
+ export const sliceTests = {
148
+ description: 'Verificação PBT de invariantes para hello-world',
149
+ cases: [
150
+ {
151
+ name: 'nome válido produz saudação que o contém',
152
+ run: async () => {
153
+ fc.assert(
154
+ fc.asyncProperty(fc.string({ minLength: 2, maxLength: 50 }), async (validName) => {
155
+ const result = await helloWorldAction({ name: validName }, new MockDatabaseClient());
156
+ return result.ok === true && result.value.greeting.includes(validName);
157
+ })
158
+ );
159
+ }
160
+ },
161
+ {
162
+ name: 'sem conexão de banco retorna NO_DATABASE',
163
+ run: async () => {
164
+ const result = await helloWorldAction({ name: 'Ismael' });
165
+
166
+ if (result.ok || result.error !== 'NO_DATABASE') {
167
+ throw new Error(`Invariante violada: esperava NO_DATABASE, obteve ${JSON.stringify(result)}`);
168
+ }
169
+ }
170
+ }
171
+ ]
172
+ };
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "esModuleInterop": true,
11
+ "allowSyntheticDefaultImports": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "types": ["bun"],
14
+ "paths": {
15
+ "synapsejs": ["./node_modules/@ismaelsoilet/synapsejs"],
16
+ "synapsejs/*": ["./node_modules/@ismaelsoilet/synapsejs/*"]
17
+ }
18
+ },
19
+ "include": ["src/**/*"],
20
+ "exclude": ["node_modules", ".codebase", ".synapse"]
21
+ }