@ismaelsoilet/synapsejs 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/bin/synapse.ts +919 -0
- package/package.json +70 -0
- package/src/client/components.tsx +508 -0
- package/src/client/context.tsx +62 -0
- package/src/client/hooks.ts +152 -0
- package/src/client/i18n.ts +40 -0
- package/src/client.ts +66 -0
- package/src/compiler/agent-diagnostic-json.ts +113 -0
- package/src/compiler/ast-daemon-compressor.ts +190 -0
- package/src/compiler/client-bundler.ts +356 -0
- package/src/compiler/context-bench.ts +191 -0
- package/src/compiler/contract.ts +260 -0
- package/src/compiler/db-schema-generator.ts +306 -0
- package/src/compiler/fields-parser.ts +267 -0
- package/src/compiler/impact-analyzer.ts +279 -0
- package/src/compiler/migration-runner.ts +555 -0
- package/src/compiler/oracle-runner.ts +293 -0
- package/src/compiler/scaffolder.ts +528 -0
- package/src/compiler/schema-dag.ts +137 -0
- package/src/compiler/schema-drift.ts +184 -0
- package/src/compiler/slice-discovery.ts +154 -0
- package/src/compiler/slice-splitter.ts +838 -0
- package/src/compiler/slice-templates.ts +1527 -0
- package/src/compiler/standalone-builder.ts +117 -0
- package/src/core/action-context.ts +152 -0
- package/src/core/config.ts +77 -0
- package/src/core/database-client.ts +246 -0
- package/src/core/database-factory.ts +46 -0
- package/src/core/image-optimizer.ts +83 -0
- package/src/core/index.ts +14 -0
- package/src/core/jobs.ts +53 -0
- package/src/core/machine-types.ts +75 -0
- package/src/core/oracle.ts +20 -0
- package/src/core/postgres-client.ts +164 -0
- package/src/core/postgres-queue.ts +288 -0
- package/src/core/query-builder.ts +343 -0
- package/src/core/rpc-client.ts +70 -0
- package/src/core/session-context.ts +87 -0
- package/src/core/session-cookie.ts +81 -0
- package/src/core/session-token.ts +170 -0
- package/src/core/sqlite-client.ts +201 -0
- package/src/core/storage.ts +317 -0
- package/src/index.ts +133 -0
- package/src/mcp/server.ts +553 -0
- package/src/runtime/client-entry.ts +65 -0
- package/src/runtime/discovery-rules.ts +63 -0
- package/src/runtime/event-hub.ts +97 -0
- package/src/runtime/postgres-event-hub.ts +148 -0
- package/src/runtime/queue-engine.ts +255 -0
- package/src/runtime/rate-limiter.ts +152 -0
- package/src/runtime/server.ts +1550 -0
- package/src/runtime/uploads.ts +192 -0
- package/templates/starter/.dockerignore +7 -0
- package/templates/starter/Dockerfile +40 -0
- package/templates/starter/README.md +40 -0
- package/templates/starter/gitignore +6 -0
- package/templates/starter/package.json +34 -0
- package/templates/starter/src/slices/welcome/hello-world.slice.tsx +172 -0
- package/templates/starter/tsconfig.json +21 -0
|
@@ -0,0 +1,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,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,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
|
+
}
|