@ismaelsoilet/synapsejs 1.8.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.
- package/README.md +98 -6
- package/bin/synapse.ts +291 -241
- package/package.json +2 -2
- package/src/client/components.tsx +459 -1
- package/src/client/context.tsx +23 -3
- package/src/client/error-boundary.tsx +59 -0
- package/src/client/hooks.ts +100 -1
- package/src/client/toast.tsx +219 -0
- package/src/client/upload.ts +153 -0
- package/src/client.ts +37 -2
- package/src/compiler/client-bundler.ts +94 -2
- package/src/compiler/context-bench.ts +9 -1
- package/src/compiler/contract.ts +31 -4
- package/src/compiler/db-schema-generator.ts +39 -8
- package/src/compiler/fields-parser.ts +28 -1
- package/src/compiler/impact-analyzer.ts +31 -1
- package/src/compiler/migration-runner.ts +10 -2
- package/src/compiler/optional-coverage.ts +137 -0
- package/src/compiler/oracle-runner.ts +18 -4
- package/src/compiler/scaffolder.ts +227 -9
- package/src/compiler/slice-discovery.ts +8 -1
- package/src/compiler/slice-splitter.ts +68 -6
- package/src/compiler/slice-templates.ts +234 -10
- package/src/compiler/typescript-api.ts +33 -0
- package/src/core/action-context.ts +101 -15
- package/src/core/config.ts +88 -10
- package/src/core/cron-parser.ts +167 -0
- package/src/core/database-client.ts +103 -6
- package/src/core/index.ts +5 -0
- package/src/core/jobs.ts +37 -0
- package/src/core/oauth-client.ts +322 -0
- package/src/core/path-guard.ts +105 -0
- package/src/core/postgres-client.ts +37 -0
- package/src/core/postgres-queue.ts +76 -9
- package/src/core/query-builder.ts +166 -34
- package/src/core/session-context.ts +45 -4
- package/src/core/session-token.ts +124 -8
- package/src/core/sqlite-client.ts +20 -0
- package/src/core/storage.ts +60 -19
- package/src/core/topics.ts +84 -0
- package/src/core/webhook-signature.ts +151 -0
- package/src/index.ts +2 -1
- package/src/mcp/server.ts +317 -218
- package/src/mcp/tools.ts +329 -0
- package/src/project-status.ts +433 -0
- package/src/runtime/client-entry.ts +11 -5
- package/src/runtime/network-guard.ts +3 -1
- package/src/runtime/queue-engine.ts +167 -5
- package/src/runtime/rate-limiter.ts +14 -4
- package/src/runtime/security-headers.ts +35 -0
- package/src/runtime/server.ts +1174 -236
- package/src/runtime/uploads.ts +199 -19
- package/src/version.ts +9 -2
- package/templates/starter/package.json +2 -2
- package/templates/starter/tsconfig.json +1 -0
|
@@ -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
|
-
|
|
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}`);
|
|
@@ -174,7 +176,7 @@ export function measureAppSurface(appRoot: string): AppSurface {
|
|
|
174
176
|
|
|
175
177
|
export function renderSurfaceReport(surfaces: AppSurface[]): string {
|
|
176
178
|
const lines = [
|
|
177
|
-
'| app | feature | arquivos (app) | tokens (app, est.) | arquivos (total) | tokens (total, est
|
|
179
|
+
'| app | feature | arquivos (app) | tokens (app, est.) | arquivos (total, não comparável) | tokens (total, est., não comparável) |',
|
|
178
180
|
'|---|---|---|---|---|---|'
|
|
179
181
|
];
|
|
180
182
|
|
|
@@ -187,5 +189,11 @@ export function renderSurfaceReport(surfaces: AppSurface[]): string {
|
|
|
187
189
|
}
|
|
188
190
|
}
|
|
189
191
|
|
|
192
|
+
lines.push(
|
|
193
|
+
'',
|
|
194
|
+
'As colunas `app` são as comparáveis (os arquivos da própria funcionalidade em cada aplicação).',
|
|
195
|
+
'As colunas `total` cobrem conjuntos de arquivos diferentes por repositório e não são comparáveis'
|
|
196
|
+
);
|
|
197
|
+
|
|
190
198
|
return lines.join('\n');
|
|
191
199
|
}
|
package/src/compiler/contract.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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-domain <domain> [--fields=<grammar>] · 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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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',
|
|
@@ -385,9 +385,15 @@ export function extractSliceSchema(sourceFile: ts.SourceFile): string | null {
|
|
|
385
385
|
return ddl;
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
+
export interface RunMigrationsOptions {
|
|
389
|
+
/** Consult the Jev abort gate after a failure (default: disabled). */
|
|
390
|
+
enableJevGate?: boolean;
|
|
391
|
+
}
|
|
392
|
+
|
|
388
393
|
export async function runSliceMigrations(
|
|
389
394
|
baseDir: string = process.cwd(),
|
|
390
|
-
customDb?: DatabaseClient
|
|
395
|
+
customDb?: DatabaseClient,
|
|
396
|
+
options: RunMigrationsOptions = {}
|
|
391
397
|
): Promise<MigrationReport> {
|
|
392
398
|
const db = customDb || getDatabase();
|
|
393
399
|
const isPostgres = db instanceof PostgresDatabaseClient;
|
|
@@ -531,8 +537,10 @@ export async function runSliceMigrations(
|
|
|
531
537
|
}
|
|
532
538
|
}
|
|
533
539
|
|
|
540
|
+
// Opt-in (default: disabled): the abort gate consults an external model provider
|
|
541
|
+
// when one is configured, so a programmatic caller asks for it explicitly.
|
|
534
542
|
let abortSignal: AbortGateResult | undefined;
|
|
535
|
-
if (hasFailure) {
|
|
543
|
+
if (hasFailure && options?.enableJevGate === true) {
|
|
536
544
|
try {
|
|
537
545
|
const failureDetails = migrations
|
|
538
546
|
.filter((m) => m.status === 'FAILED')
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - Optional Contract Item Coverage
|
|
3
|
+
*
|
|
4
|
+
* The slice authoring contract has optional items — webhook, background job,
|
|
5
|
+
* websocket definition, cache definition, metadata generation, broadcast and
|
|
6
|
+
* subscription. Prose cannot prove they work; a committed slice can. This module
|
|
7
|
+
* reports which optional item each shipped application exercises and which ones
|
|
8
|
+
* nothing exercises, so a capability cannot be advertised on a template assertion.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import { findSliceFiles, resolveSlicesDir } from './slice-discovery';
|
|
14
|
+
|
|
15
|
+
export interface OptionalItemCoverage {
|
|
16
|
+
item: string;
|
|
17
|
+
/** The committed slice that exercises it, or null when nothing does. */
|
|
18
|
+
slice: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OptionalItemReport {
|
|
22
|
+
status: 'PASS' | 'FAIL';
|
|
23
|
+
operation: 'OPTIONAL_ITEM_COVERAGE';
|
|
24
|
+
appDir: string;
|
|
25
|
+
totalSlices: number;
|
|
26
|
+
items: OptionalItemCoverage[];
|
|
27
|
+
/** The optional items no committed slice exercises. Empty is the passing state. */
|
|
28
|
+
unexercised: string[];
|
|
29
|
+
/** Present when slice discovery failed. */
|
|
30
|
+
code?: string;
|
|
31
|
+
message?: string;
|
|
32
|
+
candidates?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Detector {
|
|
36
|
+
item: string;
|
|
37
|
+
matches: (filePath: string, source: string) => boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function exportsName(source: string, suffix: string): boolean {
|
|
41
|
+
return new RegExp(`export\\s+(?:async\\s+)?(?:function|const|class|let|var)\\s+\\w*${suffix}\\b`).test(source);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DETECTORS: Detector[] = [
|
|
45
|
+
{
|
|
46
|
+
item: 'webhook',
|
|
47
|
+
matches: (_file, source) => exportsName(source, 'Webhook')
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
item: 'background job',
|
|
51
|
+
matches: (_file, source) => exportsName(source, 'Job') || /\bdefineJob\s*[<(]/.test(source)
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
item: 'websocket definition',
|
|
55
|
+
matches: (_file, source) => exportsName(source, 'Socket') || /\bdefineSocket\s*[<(]/.test(source)
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
item: 'cache definition',
|
|
59
|
+
matches: (_file, source) => exportsName(source, 'Cache') || /\bdefineCache\s*\(/.test(source)
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
item: 'metadata generation',
|
|
63
|
+
matches: (_file, source) => /\bsliceMeta\b/.test(source) || exportsName(source, 'Meta')
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
item: 'broadcast',
|
|
67
|
+
matches: (_file, source) => /\.broadcast\s*\(/.test(source)
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
item: 'subscription',
|
|
71
|
+
matches: (_file, source) => /\buseSubscription\s*\(/.test(source) || /\bdefineTopic\s*\(/.test(source)
|
|
72
|
+
}
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
export function analyzeOptionalItemCoverage(appDir: string = process.cwd()): OptionalItemReport {
|
|
76
|
+
const resolution = resolveSlicesDir(appDir);
|
|
77
|
+
|
|
78
|
+
if (!resolution.ok) {
|
|
79
|
+
return {
|
|
80
|
+
status: 'FAIL',
|
|
81
|
+
operation: 'OPTIONAL_ITEM_COVERAGE',
|
|
82
|
+
appDir,
|
|
83
|
+
totalSlices: 0,
|
|
84
|
+
items: DETECTORS.map((detector) => ({ item: detector.item, slice: null })),
|
|
85
|
+
unexercised: DETECTORS.map((detector) => detector.item),
|
|
86
|
+
code: resolution.error.code,
|
|
87
|
+
message: resolution.error.message,
|
|
88
|
+
candidates: resolution.error.candidates
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const sliceFiles = findSliceFiles(resolution.value.slicesDir);
|
|
93
|
+
const items: OptionalItemCoverage[] = DETECTORS.map((detector) => {
|
|
94
|
+
for (const filePath of sliceFiles) {
|
|
95
|
+
try {
|
|
96
|
+
const source = fs.readFileSync(filePath, 'utf-8');
|
|
97
|
+
|
|
98
|
+
if (detector.matches(filePath, source)) {
|
|
99
|
+
return { item: detector.item, slice: path.basename(filePath, '.slice.tsx') };
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// Unreadable slice: it cannot be the evidence, and discovery already reported it.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { item: detector.item, slice: null };
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const unexercised = items.filter((entry) => entry.slice === null).map((entry) => entry.item);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
status: unexercised.length === 0 ? 'PASS' : 'FAIL',
|
|
113
|
+
operation: 'OPTIONAL_ITEM_COVERAGE',
|
|
114
|
+
appDir,
|
|
115
|
+
totalSlices: sliceFiles.length,
|
|
116
|
+
items,
|
|
117
|
+
unexercised
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The union of optional items covered across several shipped applications. */
|
|
122
|
+
export function unionCoverage(reports: OptionalItemReport[]): { covered: string[]; unexercised: string[] } {
|
|
123
|
+
const covered = new Set<string>();
|
|
124
|
+
|
|
125
|
+
for (const report of reports) {
|
|
126
|
+
for (const entry of report.items) {
|
|
127
|
+
if (entry.slice) {
|
|
128
|
+
covered.add(entry.item);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
covered: Array.from(covered),
|
|
135
|
+
unexercised: DETECTORS.map((detector) => detector.item).filter((item) => !covered.has(item))
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -156,7 +156,14 @@ 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 */
|
|
162
|
+
/**
|
|
163
|
+
* Opt-in (default: disabled). The triage consults an external model provider when
|
|
164
|
+
* one is configured, so a programmatic caller must ask for it explicitly; the CLI
|
|
165
|
+
* enables it only when the environment declares the gate.
|
|
166
|
+
*/
|
|
160
167
|
enableJevTriage?: boolean;
|
|
161
168
|
}
|
|
162
169
|
|
|
@@ -191,7 +198,14 @@ export async function runSliceOracles(
|
|
|
191
198
|
}
|
|
192
199
|
|
|
193
200
|
const slicesDir = resolution.value.slicesDir;
|
|
194
|
-
const
|
|
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;
|
|
195
209
|
const oraclesDir = path.join(root, '.synapse', ORACLE_DIR_NAME);
|
|
196
210
|
const reportPath = path.join(oraclesDir, 'report.xml');
|
|
197
211
|
|
|
@@ -246,7 +260,7 @@ export async function runSliceOracles(
|
|
|
246
260
|
|
|
247
261
|
if (!fs.existsSync(reportPath)) {
|
|
248
262
|
let triageResult: TestTriageResult | undefined;
|
|
249
|
-
if (options.enableJevTriage
|
|
263
|
+
if (options.enableJevTriage === true) {
|
|
250
264
|
try {
|
|
251
265
|
triageResult = await triageTestFailure(output.trim() || `O runner Bun test falhou com exit ${exitCode}`);
|
|
252
266
|
} catch {}
|
|
@@ -280,7 +294,7 @@ export async function runSliceOracles(
|
|
|
280
294
|
|
|
281
295
|
if (totalCases === 0) {
|
|
282
296
|
let triageResult: TestTriageResult | undefined;
|
|
283
|
-
if (options.enableJevTriage
|
|
297
|
+
if (options.enableJevTriage === true) {
|
|
284
298
|
try {
|
|
285
299
|
triageResult = await triageTestFailure(`Nenhum invariante foi executado em ${results.length} fatia(s).`);
|
|
286
300
|
} catch {}
|
|
@@ -301,7 +315,7 @@ export async function runSliceOracles(
|
|
|
301
315
|
|
|
302
316
|
const isPass = passedSlices === results.length;
|
|
303
317
|
let triageResult: TestTriageResult | undefined;
|
|
304
|
-
if (!isPass && options.enableJevTriage
|
|
318
|
+
if (!isPass && options.enableJevTriage === true) {
|
|
305
319
|
try {
|
|
306
320
|
const failedSummaries = results
|
|
307
321
|
.filter((r) => !r.passed)
|