@ismaelsoilet/synapsejs 1.9.0 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/bin/synapse.ts +74 -2
- 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 +2 -0
- package/src/compiler/contract.ts +1 -1
- package/src/compiler/oracle-runner.ts +10 -1
- package/src/compiler/scaffolder.ts +113 -4
- package/src/compiler/slice-splitter.ts +21 -3
- package/src/compiler/slice-templates.ts +234 -10
- package/src/compiler/typescript-api.ts +33 -0
- package/src/core/action-context.ts +50 -1
- package/src/core/cron-parser.ts +167 -0
- package/src/core/database-client.ts +35 -0
- package/src/core/index.ts +2 -0
- package/src/core/jobs.ts +37 -0
- package/src/core/oauth-client.ts +322 -0
- package/src/core/postgres-client.ts +37 -0
- package/src/core/postgres-queue.ts +51 -2
- package/src/core/query-builder.ts +166 -34
- package/src/core/session-context.ts +45 -4
- package/src/core/session-token.ts +16 -1
- package/src/core/sqlite-client.ts +20 -0
- package/src/mcp/server.ts +257 -1
- package/src/mcp/tools.ts +92 -1
- package/src/project-status.ts +103 -0
- package/src/runtime/client-entry.ts +11 -5
- package/src/runtime/queue-engine.ts +120 -5
- package/src/runtime/security-headers.ts +35 -0
- package/src/runtime/server.ts +300 -58
- package/templates/starter/package.json +2 -2
|
@@ -12,7 +12,15 @@
|
|
|
12
12
|
|
|
13
13
|
import { generateTableColumnsCode, parseFields } from './fields-parser';
|
|
14
14
|
|
|
15
|
-
export type SliceTemplate =
|
|
15
|
+
export type SliceTemplate =
|
|
16
|
+
| 'create'
|
|
17
|
+
| 'list'
|
|
18
|
+
| 'update'
|
|
19
|
+
| 'delete'
|
|
20
|
+
| 'login'
|
|
21
|
+
| 'oauth-github'
|
|
22
|
+
| 'auth-2fa'
|
|
23
|
+
| 'modal-crud';
|
|
16
24
|
export const SLICE_TEMPLATES: SliceTemplate[] = [
|
|
17
25
|
'create',
|
|
18
26
|
'list',
|
|
@@ -20,7 +28,8 @@ export const SLICE_TEMPLATES: SliceTemplate[] = [
|
|
|
20
28
|
'delete',
|
|
21
29
|
'login',
|
|
22
30
|
'oauth-github',
|
|
23
|
-
'auth-2fa'
|
|
31
|
+
'auth-2fa',
|
|
32
|
+
'modal-crud'
|
|
24
33
|
];
|
|
25
34
|
|
|
26
35
|
/** Paginação das listas geradas: o agente não escolhe esses números por conta. */
|
|
@@ -868,8 +877,8 @@ export async function ${names.actionName}(
|
|
|
868
877
|
const input = payload as ${names.inputName};
|
|
869
878
|
const email = input.email.trim().toLowerCase();
|
|
870
879
|
|
|
871
|
-
const rows = await db.query<{ id: string; password_hash: string; roles: string }>(
|
|
872
|
-
'SELECT
|
|
880
|
+
const rows = await db.query<{ id: string; password_hash: string; roles: string; permissions?: string }>(
|
|
881
|
+
'SELECT * FROM users WHERE email = $1',
|
|
873
882
|
[email]
|
|
874
883
|
);
|
|
875
884
|
const user = rows[0];
|
|
@@ -884,10 +893,15 @@ export async function ${names.actionName}(
|
|
|
884
893
|
.map((role) => role.trim())
|
|
885
894
|
.filter((role) => role.length > 0);
|
|
886
895
|
|
|
896
|
+
const permissions = (user.permissions ?? '')
|
|
897
|
+
.split(',')
|
|
898
|
+
.map((permission) => permission.trim())
|
|
899
|
+
.filter((permission) => permission.length > 0);
|
|
900
|
+
|
|
887
901
|
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_MAX_AGE_SECONDS;
|
|
888
902
|
|
|
889
903
|
return Ok({
|
|
890
|
-
token: signSessionToken({ userId: user.id, roles }, secret, SESSION_MAX_AGE_SECONDS),
|
|
904
|
+
token: signSessionToken({ userId: user.id, roles, permissions }, secret, SESSION_MAX_AGE_SECONDS),
|
|
891
905
|
roles,
|
|
892
906
|
expiresAt
|
|
893
907
|
});
|
|
@@ -971,8 +985,8 @@ export const sliceTests = {
|
|
|
971
985
|
run: async () => {
|
|
972
986
|
process.env.SYNAPSE_SESSION_SECRET = 'segredo-de-teste';
|
|
973
987
|
const hash = await Bun.password.hash('senha-com-oito');
|
|
974
|
-
const db = new MockDatabaseClient().onQuery(/SELECT
|
|
975
|
-
{ id: 'user-1', password_hash: hash, roles: 'admin,billing' }
|
|
988
|
+
const db = new MockDatabaseClient().onQuery(/SELECT .* FROM users/, () => [
|
|
989
|
+
{ id: 'user-1', password_hash: hash, roles: 'admin,billing', permissions: 'orders:read' }
|
|
976
990
|
]);
|
|
977
991
|
|
|
978
992
|
const result = await ${names.actionName}({ email: ' Admin@Loja.com ', password: 'senha-com-oito' }, db);
|
|
@@ -985,7 +999,11 @@ export const sliceTests = {
|
|
|
985
999
|
if (!verified.ok) {
|
|
986
1000
|
throw new Error(\`o servidor recusaria o token emitido: \${verified.error}\`);
|
|
987
1001
|
}
|
|
988
|
-
if (
|
|
1002
|
+
if (
|
|
1003
|
+
verified.value.userId !== 'user-1' ||
|
|
1004
|
+
!verified.value.roles.includes('billing') ||
|
|
1005
|
+
!verified.value.permissions?.includes('orders:read')
|
|
1006
|
+
) {
|
|
989
1007
|
throw new Error(\`claims inesperadas: \${JSON.stringify(verified.value)}\`);
|
|
990
1008
|
}
|
|
991
1009
|
if (result.value.roles.join(',') !== 'admin,billing') {
|
|
@@ -998,7 +1016,7 @@ export const sliceTests = {
|
|
|
998
1016
|
run: async () => {
|
|
999
1017
|
process.env.SYNAPSE_SESSION_SECRET = 'segredo-de-teste';
|
|
1000
1018
|
const hash = await Bun.password.hash('senha-com-oito');
|
|
1001
|
-
const db = new MockDatabaseClient().onQuery(/SELECT
|
|
1019
|
+
const db = new MockDatabaseClient().onQuery(/SELECT .* FROM users/, () => [
|
|
1002
1020
|
{ id: 'user-1', password_hash: hash, roles: 'user' }
|
|
1003
1021
|
]);
|
|
1004
1022
|
|
|
@@ -1013,7 +1031,7 @@ export const sliceTests = {
|
|
|
1013
1031
|
name: 'usuário inexistente retorna INVALID_CREDENTIALS sem revelar que não existe',
|
|
1014
1032
|
run: async () => {
|
|
1015
1033
|
process.env.SYNAPSE_SESSION_SECRET = 'segredo-de-teste';
|
|
1016
|
-
const db = new MockDatabaseClient().onQuery(/SELECT
|
|
1034
|
+
const db = new MockDatabaseClient().onQuery(/SELECT .* FROM users/, () => []);
|
|
1017
1035
|
|
|
1018
1036
|
const result = await ${names.actionName}({ email: 'ninguem@b.com', password: 'senha-com-oito' }, db);
|
|
1019
1037
|
|
|
@@ -1510,6 +1528,210 @@ export const sliceTests = {
|
|
|
1510
1528
|
`;
|
|
1511
1529
|
}
|
|
1512
1530
|
|
|
1531
|
+
function modalCrudTemplate(names: TemplateNames, fieldsSpec?: string): string {
|
|
1532
|
+
const fields = parseFields(fieldsSpec || 'name:string,description:string,status:enum(active|archived)');
|
|
1533
|
+
const ddlColumns = generateTableColumnsCode(fields);
|
|
1534
|
+
const tbProps = fields.map((f) => ` ${f.name}: Type.String()`).join(',\n');
|
|
1535
|
+
const formFields = fields
|
|
1536
|
+
.map((f) => `{ name: '${f.name}', label: '${f.name}', type: 'text', required: true }`)
|
|
1537
|
+
.join(', ');
|
|
1538
|
+
|
|
1539
|
+
return `import React, { useState } from 'react';
|
|
1540
|
+
import { Type, Static } from '@sinclair/typebox';
|
|
1541
|
+
import { Value } from '@sinclair/typebox/value';
|
|
1542
|
+
import * as fc from 'fast-check';
|
|
1543
|
+
import {
|
|
1544
|
+
type DatabaseClient,
|
|
1545
|
+
Err,
|
|
1546
|
+
MockDatabaseClient,
|
|
1547
|
+
Ok,
|
|
1548
|
+
type Result,
|
|
1549
|
+
type SessionContext,
|
|
1550
|
+
requireAuth
|
|
1551
|
+
} from 'synapsejs';
|
|
1552
|
+
import {
|
|
1553
|
+
Button,
|
|
1554
|
+
Card,
|
|
1555
|
+
DataForm,
|
|
1556
|
+
DataTable,
|
|
1557
|
+
Drawer,
|
|
1558
|
+
type FormField,
|
|
1559
|
+
Modal,
|
|
1560
|
+
useToast
|
|
1561
|
+
} from 'synapsejs/client';
|
|
1562
|
+
|
|
1563
|
+
// ============================================================================
|
|
1564
|
+
// 1. CONTRATO DE ENTRADA (TypeBox)
|
|
1565
|
+
// ============================================================================
|
|
1566
|
+
export const ${names.schemaName} = Type.Object({
|
|
1567
|
+
${tbProps}
|
|
1568
|
+
}, { additionalProperties: true });
|
|
1569
|
+
export type ${names.inputName} = Static<typeof ${names.schemaName}>;
|
|
1570
|
+
|
|
1571
|
+
// ============================================================================
|
|
1572
|
+
// 2. DDL SCHEMA DECLARATIVO
|
|
1573
|
+
// ============================================================================
|
|
1574
|
+
export const sliceSchema = \`
|
|
1575
|
+
-- up:
|
|
1576
|
+
CREATE TABLE IF NOT EXISTS ${names.table} (
|
|
1577
|
+
id TEXT PRIMARY KEY,
|
|
1578
|
+
${ddlColumns},
|
|
1579
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
1580
|
+
);
|
|
1581
|
+
-- down:
|
|
1582
|
+
DROP TABLE IF EXISTS ${names.table};
|
|
1583
|
+
\`;
|
|
1584
|
+
|
|
1585
|
+
// ============================================================================
|
|
1586
|
+
// 3. MODELAGEM DE DOMÍNIO
|
|
1587
|
+
// ============================================================================
|
|
1588
|
+
export interface ${names.pascal}Row {
|
|
1589
|
+
id: string;
|
|
1590
|
+
name: string;
|
|
1591
|
+
[key: string]: unknown;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
export type ${names.outputName} = Result<
|
|
1595
|
+
${names.pascal}Row,
|
|
1596
|
+
'INVALID_SCHEMA' | 'NO_DATABASE' | 'UNAUTHORIZED'
|
|
1597
|
+
>;
|
|
1598
|
+
|
|
1599
|
+
// ============================================================================
|
|
1600
|
+
// 4. SERVER ACTION
|
|
1601
|
+
// ============================================================================
|
|
1602
|
+
export async function ${names.actionName}(
|
|
1603
|
+
payload: unknown,
|
|
1604
|
+
db?: DatabaseClient,
|
|
1605
|
+
session?: SessionContext
|
|
1606
|
+
): Promise<${names.outputName}> {
|
|
1607
|
+
if (!db) return Err('NO_DATABASE');
|
|
1608
|
+
if (!Value.Check(${names.schemaName}, payload)) return Err('INVALID_SCHEMA');
|
|
1609
|
+
|
|
1610
|
+
const id = crypto.randomUUID();
|
|
1611
|
+
const record: Record<string, unknown> = {
|
|
1612
|
+
id,
|
|
1613
|
+
...(payload as Record<string, unknown>),
|
|
1614
|
+
created_at: new Date().toISOString()
|
|
1615
|
+
};
|
|
1616
|
+
|
|
1617
|
+
await db.insert('${names.table}', record);
|
|
1618
|
+
return Ok(record as unknown as ${names.pascal}Row);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// ============================================================================
|
|
1622
|
+
// 5. SERVER LOADER (SSR)
|
|
1623
|
+
// ============================================================================
|
|
1624
|
+
export async function ${names.pascal}Loader(context: { db: DatabaseClient }) {
|
|
1625
|
+
const rows = await context.db.findMany<${names.pascal}Row>('${names.table}', { limit: 50 });
|
|
1626
|
+
return { rows };
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// ============================================================================
|
|
1630
|
+
// 6. REACT UI — MODAL CRUD COM DRAWER E TOAST
|
|
1631
|
+
// ============================================================================
|
|
1632
|
+
const FORM_FIELDS: FormField[] = [${formFields}];
|
|
1633
|
+
|
|
1634
|
+
export interface ${names.componentName}Props {
|
|
1635
|
+
rows?: ${names.pascal}Row[];
|
|
1636
|
+
onSubmitAction?: (payload: unknown) => Promise<${names.outputName}>;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
export function ${names.componentName}({ rows = [], onSubmitAction }: ${names.componentName}Props) {
|
|
1640
|
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
1641
|
+
const [selectedItem, setSelectedItem] = useState<${names.pascal}Row | null>(null);
|
|
1642
|
+
const toast = useToast();
|
|
1643
|
+
|
|
1644
|
+
const handleCreate = async (formData: Record<string, unknown>) => {
|
|
1645
|
+
if (!onSubmitAction) return;
|
|
1646
|
+
const res = await onSubmitAction(formData);
|
|
1647
|
+
if (res.ok) {
|
|
1648
|
+
toast.success('Registro criado com sucesso!', '${names.pascal}');
|
|
1649
|
+
setIsModalOpen(false);
|
|
1650
|
+
} else {
|
|
1651
|
+
toast.error('Erro ao salvar: ' + res.error);
|
|
1652
|
+
}
|
|
1653
|
+
};
|
|
1654
|
+
|
|
1655
|
+
return (
|
|
1656
|
+
<div className="space-y-6">
|
|
1657
|
+
<div className="flex items-center justify-between">
|
|
1658
|
+
<div>
|
|
1659
|
+
<h2 className="text-xl font-bold text-white tracking-tight">${names.pascal}</h2>
|
|
1660
|
+
<p className="text-xs text-slate-400">Gerenciado com Modal e Drawer integrados</p>
|
|
1661
|
+
</div>
|
|
1662
|
+
<Button onClick={() => setIsModalOpen(true)}>➕ Novo Registro</Button>
|
|
1663
|
+
</div>
|
|
1664
|
+
|
|
1665
|
+
<Card>
|
|
1666
|
+
<DataTable
|
|
1667
|
+
data={rows}
|
|
1668
|
+
columns={[
|
|
1669
|
+
{ key: 'id', header: 'ID', render: (val) => <span className="font-mono text-xs">{String(val).slice(0, 8)}...</span> },
|
|
1670
|
+
{ key: 'name', header: 'Nome' },
|
|
1671
|
+
{
|
|
1672
|
+
key: 'actions',
|
|
1673
|
+
header: 'Ações',
|
|
1674
|
+
render: (_, row) => (
|
|
1675
|
+
<Button size="sm" variant="secondary" onClick={() => setSelectedItem(row)}>
|
|
1676
|
+
Ver Detalhes
|
|
1677
|
+
</Button>
|
|
1678
|
+
)
|
|
1679
|
+
}
|
|
1680
|
+
]}
|
|
1681
|
+
/>
|
|
1682
|
+
</Card>
|
|
1683
|
+
|
|
1684
|
+
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} title="Cadastrar ${names.pascal}">
|
|
1685
|
+
<DataForm fields={FORM_FIELDS} onSubmit={handleCreate} submitLabel="Salvar" />
|
|
1686
|
+
</Modal>
|
|
1687
|
+
|
|
1688
|
+
<Drawer
|
|
1689
|
+
isOpen={Boolean(selectedItem)}
|
|
1690
|
+
onClose={() => setSelectedItem(null)}
|
|
1691
|
+
title="Detalhes do Registro"
|
|
1692
|
+
position="right"
|
|
1693
|
+
>
|
|
1694
|
+
{selectedItem && (
|
|
1695
|
+
<div className="space-y-4 text-xs font-mono">
|
|
1696
|
+
<pre className="p-4 bg-slate-950 rounded-xl border border-slate-800 text-slate-300 overflow-x-auto">
|
|
1697
|
+
{JSON.stringify(selectedItem, null, 2)}
|
|
1698
|
+
</pre>
|
|
1699
|
+
</div>
|
|
1700
|
+
)}
|
|
1701
|
+
</Drawer>
|
|
1702
|
+
</div>
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// ============================================================================
|
|
1707
|
+
// 7. ORÁCULO DE TESTES (PBT)
|
|
1708
|
+
// ============================================================================
|
|
1709
|
+
export const sliceTests = {
|
|
1710
|
+
description: 'Garantias invariantes do ${names.pascal} Modal CRUD',
|
|
1711
|
+
cases: [
|
|
1712
|
+
{
|
|
1713
|
+
name: 'payload inválido retorna INVALID_SCHEMA',
|
|
1714
|
+
run: async () => {
|
|
1715
|
+
const res = await ${names.actionName}({ invalidField: 123 }, new MockDatabaseClient());
|
|
1716
|
+
if (res.ok || res.error !== 'INVALID_SCHEMA') {
|
|
1717
|
+
throw new Error('Deveria recusar payload fora do schema');
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
},
|
|
1721
|
+
{
|
|
1722
|
+
name: 'sem conexão de banco retorna NO_DATABASE',
|
|
1723
|
+
run: async () => {
|
|
1724
|
+
const res = await ${names.actionName}({ name: 'Teste' });
|
|
1725
|
+
if (res.ok || res.error !== 'NO_DATABASE') {
|
|
1726
|
+
throw new Error('Deveria acusar NO_DATABASE');
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
]
|
|
1731
|
+
};
|
|
1732
|
+
`;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1513
1735
|
export function generateOperationTemplate(
|
|
1514
1736
|
domain: string,
|
|
1515
1737
|
sliceName: string,
|
|
@@ -1531,6 +1753,8 @@ export function generateOperationTemplate(
|
|
|
1531
1753
|
return oauthGithubTemplate(names);
|
|
1532
1754
|
case 'auth-2fa':
|
|
1533
1755
|
return auth2faTemplate(names);
|
|
1756
|
+
case 'modal-crud':
|
|
1757
|
+
return modalCrudTemplate(names, fieldsSpec);
|
|
1534
1758
|
default:
|
|
1535
1759
|
throw new Error(`Template desconhecido: ${template}`);
|
|
1536
1760
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - TypeScript compiler API guard.
|
|
3
|
+
*
|
|
4
|
+
* The splitter, checker and repo-map generator use the in-process JavaScript
|
|
5
|
+
* compiler API. TypeScript 7 (the native compiler) ships a CLI-only package and
|
|
6
|
+
* removed that API, so resolving it must fail with a named, actionable error
|
|
7
|
+
* instead of a `TypeError` deep inside the compiler.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as ts from 'typescript';
|
|
11
|
+
|
|
12
|
+
export const COMPILER_API_UNAVAILABLE = 'COMPILER_API_UNAVAILABLE';
|
|
13
|
+
|
|
14
|
+
interface CompilerModuleShape {
|
|
15
|
+
version?: string;
|
|
16
|
+
sys?: { readFile?: unknown };
|
|
17
|
+
createProgram?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function assertCompilerApi(module: unknown = ts): void {
|
|
21
|
+
const candidate = module as CompilerModuleShape;
|
|
22
|
+
|
|
23
|
+
if (typeof candidate?.sys?.readFile === 'function' && typeof candidate.createProgram === 'function') {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const error = new Error(
|
|
28
|
+
`${COMPILER_API_UNAVAILABLE}: TypeScript ${candidate?.version ?? 'unknown'} does not expose the in-process compiler API required by SynapseJS. Use TypeScript 5.9.x (the pinned version) or run the compiler through its CLI.`
|
|
29
|
+
) as Error & { code: string };
|
|
30
|
+
|
|
31
|
+
error.code = COMPILER_API_UNAVAILABLE;
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
@@ -21,6 +21,8 @@ export interface StructuredLogger {
|
|
|
21
21
|
|
|
22
22
|
export interface JobEnqueueOptions {
|
|
23
23
|
delaySeconds?: number;
|
|
24
|
+
/** Absolute execution timestamp in milliseconds. Mutually exclusive with delaySeconds. */
|
|
25
|
+
runAt?: number;
|
|
24
26
|
priority?: number;
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -117,6 +119,12 @@ export interface ActionContext<TServices = Record<string, unknown>> extends Data
|
|
|
117
119
|
/** Sets an HTTP response cookie (e.g. HttpOnly session token) */
|
|
118
120
|
readonly setCookie: (name: string, value: string, options?: CookieOptions) => void;
|
|
119
121
|
|
|
122
|
+
/** Emits an HTTP chunked or SSE streaming response for real-time AI/LLM tokens */
|
|
123
|
+
readonly stream: (
|
|
124
|
+
generator: AsyncIterable<string | Uint8Array> | (() => AsyncGenerator<string | Uint8Array>),
|
|
125
|
+
options?: { contentType?: string; status?: number; headers?: Record<string, string> }
|
|
126
|
+
) => Response;
|
|
127
|
+
|
|
120
128
|
/** Internal list of pending cookies to be set on the response */
|
|
121
129
|
readonly _pendingCookies?: PendingCookie[];
|
|
122
130
|
}
|
|
@@ -162,7 +170,7 @@ export function createActionContext<TServices = Record<string, unknown>>(
|
|
|
162
170
|
const {
|
|
163
171
|
db,
|
|
164
172
|
session,
|
|
165
|
-
tenantId = session
|
|
173
|
+
tenantId = session?.tenantId,
|
|
166
174
|
services = {} as TServices,
|
|
167
175
|
logger = createDefaultLogger(),
|
|
168
176
|
enqueue = async () => 'job_noop',
|
|
@@ -196,6 +204,43 @@ export function createActionContext<TServices = Record<string, unknown>>(
|
|
|
196
204
|
pendingCookies.push({ name, value, options });
|
|
197
205
|
};
|
|
198
206
|
|
|
207
|
+
const stream = (
|
|
208
|
+
generator: AsyncIterable<string | Uint8Array> | (() => AsyncGenerator<string | Uint8Array>),
|
|
209
|
+
streamOptions?: { contentType?: string; status?: number; headers?: Record<string, string> }
|
|
210
|
+
): Response => {
|
|
211
|
+
const iterable = typeof generator === 'function' ? generator() : generator;
|
|
212
|
+
const encoder = new TextEncoder();
|
|
213
|
+
|
|
214
|
+
const readable = new ReadableStream({
|
|
215
|
+
async start(controller) {
|
|
216
|
+
try {
|
|
217
|
+
for await (const chunk of iterable) {
|
|
218
|
+
if (typeof chunk === 'string') {
|
|
219
|
+
controller.enqueue(encoder.encode(chunk));
|
|
220
|
+
} else {
|
|
221
|
+
controller.enqueue(chunk);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
controller.close();
|
|
225
|
+
} catch (err) {
|
|
226
|
+
controller.error(err);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const headers = new Headers({
|
|
232
|
+
'Content-Type': streamOptions?.contentType || 'text/plain; charset=utf-8',
|
|
233
|
+
'Transfer-Encoding': 'chunked',
|
|
234
|
+
'X-Content-Type-Options': 'nosniff',
|
|
235
|
+
...(streamOptions?.headers || {})
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
return new Response(readable, {
|
|
239
|
+
status: streamOptions?.status || 200,
|
|
240
|
+
headers
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
|
|
199
244
|
const context: ActionContext<TServices> = {
|
|
200
245
|
// ActionContext properties
|
|
201
246
|
db,
|
|
@@ -208,6 +253,7 @@ export function createActionContext<TServices = Record<string, unknown>>(
|
|
|
208
253
|
broadcast,
|
|
209
254
|
invalidateCache,
|
|
210
255
|
setCookie,
|
|
256
|
+
stream,
|
|
211
257
|
_pendingCookies: pendingCookies,
|
|
212
258
|
|
|
213
259
|
// DatabaseClient proxy methods
|
|
@@ -221,6 +267,9 @@ export function createActionContext<TServices = Record<string, unknown>>(
|
|
|
221
267
|
update: <T = unknown>(table: string, data: Record<string, unknown>, where: WhereCondition) =>
|
|
222
268
|
db.update<T>(table, data, where),
|
|
223
269
|
delete: (table: string, where: WhereCondition) => db.delete(table, where),
|
|
270
|
+
count: (table: string, where?: WhereCondition) => db.count(table, where),
|
|
271
|
+
exists: (table: string, where: WhereCondition) => db.exists(table, where),
|
|
272
|
+
aggregate: <T = Record<string, unknown>>(table: string, options: QueryOptions) => db.aggregate<T>(table, options),
|
|
224
273
|
transaction: async <T>(operation: (tx: DatabaseClient) => Promise<T>): Promise<T> => {
|
|
225
274
|
return db.transaction(async (txDb) => {
|
|
226
275
|
// Create transactional ActionContext preserving services and context
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - Zero-Dependency 5-Field Standard Cron Expression Parser
|
|
3
|
+
*
|
|
4
|
+
* Evaluates standard cron strings (minute, hour, day-of-month, month, day-of-week)
|
|
5
|
+
* and calculates the exact next UTC execution timestamp deterministically.
|
|
6
|
+
*
|
|
7
|
+
* Day-of-month and day-of-week follow POSIX/Vixie semantics: when both fields are
|
|
8
|
+
* restricted, the expression matches the union of the two (OR), not the
|
|
9
|
+
* intersection. Names, L, W, # and ? are rejected explicitly instead of silently
|
|
10
|
+
* searching for an impossible date.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface ParsedCronField {
|
|
14
|
+
matches(val: number): boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function matchesField(value: number, sundaySeven: boolean, predicate: (value: number) => boolean): boolean {
|
|
18
|
+
return predicate(value) || (sundaySeven && value === 0 && predicate(7));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseField(field: string, min: number, max: number, sundaySeven = false): ParsedCronField {
|
|
22
|
+
const trimmed = field.trim();
|
|
23
|
+
if (trimmed === '*') {
|
|
24
|
+
return { matches: () => true };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Handle step: */5 or 10-30/5
|
|
28
|
+
if (trimmed.includes('/')) {
|
|
29
|
+
const [rangePart, stepPart] = trimmed.split('/');
|
|
30
|
+
const step = Number.parseInt(stepPart, 10);
|
|
31
|
+
if (Number.isNaN(step) || step <= 0) {
|
|
32
|
+
throw new Error(`Invalid cron step: "${stepPart}" in "${field}"`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let start = min;
|
|
36
|
+
let end = max;
|
|
37
|
+
if (rangePart !== '*') {
|
|
38
|
+
if (rangePart.includes('-')) {
|
|
39
|
+
const [rawStart, rawEnd] = rangePart.split('-').map((n) => Number.parseInt(n, 10));
|
|
40
|
+
if (Number.isNaN(rawStart) || Number.isNaN(rawEnd) || rawStart < min || rawEnd > max || rawStart > rawEnd) {
|
|
41
|
+
throw new Error(`Invalid cron range: "${rangePart}" in "${field}"`);
|
|
42
|
+
}
|
|
43
|
+
start = rawStart;
|
|
44
|
+
end = rawEnd;
|
|
45
|
+
} else {
|
|
46
|
+
const rawStart = Number.parseInt(rangePart, 10);
|
|
47
|
+
if (Number.isNaN(rawStart) || rawStart < min || rawStart > max) {
|
|
48
|
+
throw new Error(`Invalid cron number: "${rangePart}" (expected ${min}-${max})`);
|
|
49
|
+
}
|
|
50
|
+
start = rawStart;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
matches: (val: number) =>
|
|
56
|
+
matchesField(val, sundaySeven, (value) => value >= start && value <= end && (value - start) % step === 0)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Handle list: 1,2,5
|
|
61
|
+
if (trimmed.includes(',')) {
|
|
62
|
+
const subParsers = trimmed.split(',').map((part) => parseField(part, min, max, sundaySeven));
|
|
63
|
+
return {
|
|
64
|
+
matches: (val: number) => subParsers.some((parser) => parser.matches(val))
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Handle range: 1-5
|
|
69
|
+
if (trimmed.includes('-')) {
|
|
70
|
+
const [start, end] = trimmed.split('-').map((n) => Number.parseInt(n, 10));
|
|
71
|
+
if (Number.isNaN(start) || Number.isNaN(end) || start > end || start < min || end > max) {
|
|
72
|
+
throw new Error(`Invalid cron range: "${field}"`);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
matches: (val: number) => matchesField(val, sundaySeven, (value) => value >= start && value <= end)
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Exact number: 5
|
|
80
|
+
const exact = Number.parseInt(trimmed, 10);
|
|
81
|
+
if (Number.isNaN(exact) || exact < min || exact > max) {
|
|
82
|
+
throw new Error(`Invalid cron number: "${trimmed}" (expected ${min}-${max})`);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
matches: (val: number) => matchesField(val, sundaySeven, (value) => value === exact)
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function parseNextCronOccurrence(cronExpr: string, fromDate: Date = new Date()): Date {
|
|
90
|
+
const parts = cronExpr.trim().split(/\s+/);
|
|
91
|
+
if (parts.length !== 5) {
|
|
92
|
+
throw new Error(`Cron expression must contain exactly 5 parts (got ${parts.length}): "${cronExpr}"`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const part of parts) {
|
|
96
|
+
if (/[A-Za-z?#]/.test(part)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Unsupported cron syntax: "${part}". Names, L, W, # and ? are not supported; use numbers, ranges, lists and steps.`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const minuteField = parseField(parts[0], 0, 59);
|
|
104
|
+
const hourField = parseField(parts[1], 0, 23);
|
|
105
|
+
const dayOfMonthField = parseField(parts[2], 1, 31);
|
|
106
|
+
const monthField = parseField(parts[3], 1, 12);
|
|
107
|
+
// Sunday is accepted as 0 or 7; the field matcher tests both representations.
|
|
108
|
+
const dayOfWeekField = parseField(parts[4], 0, 7, true);
|
|
109
|
+
|
|
110
|
+
const dayOfMonthRestricted = parts[2].trim() !== '*';
|
|
111
|
+
const dayOfWeekRestricted = parts[4].trim() !== '*';
|
|
112
|
+
|
|
113
|
+
// Start checking from the next whole minute
|
|
114
|
+
const candidate = new Date(fromDate.getTime());
|
|
115
|
+
candidate.setUTCSeconds(0, 0);
|
|
116
|
+
candidate.setUTCMinutes(candidate.getUTCMinutes() + 1);
|
|
117
|
+
|
|
118
|
+
// Limit search to 5 years (approx 2,628,000 minutes) to prevent infinite loops on impossible dates
|
|
119
|
+
const maxIterations = 5 * 365 * 24 * 60;
|
|
120
|
+
let iterations = 0;
|
|
121
|
+
|
|
122
|
+
while (iterations < maxIterations) {
|
|
123
|
+
const month = candidate.getUTCMonth() + 1; // 1-12
|
|
124
|
+
if (!monthField.matches(month)) {
|
|
125
|
+
// Advance to start of next month
|
|
126
|
+
candidate.setUTCMonth(candidate.getUTCMonth() + 1, 1);
|
|
127
|
+
candidate.setUTCHours(0, 0, 0, 0);
|
|
128
|
+
iterations += 60;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const dayOfMonth = candidate.getUTCDate();
|
|
133
|
+
const dayOfWeek = candidate.getUTCDay(); // 0 = Sunday
|
|
134
|
+
const dayOfMonthMatches = dayOfMonthField.matches(dayOfMonth);
|
|
135
|
+
const dayOfWeekMatches = dayOfWeekField.matches(dayOfWeek);
|
|
136
|
+
const dayMatches =
|
|
137
|
+
dayOfMonthRestricted && dayOfWeekRestricted
|
|
138
|
+
? dayOfMonthMatches || dayOfWeekMatches
|
|
139
|
+
: dayOfMonthMatches && dayOfWeekMatches;
|
|
140
|
+
|
|
141
|
+
if (!dayMatches) {
|
|
142
|
+
// Advance to start of next day
|
|
143
|
+
candidate.setUTCDate(candidate.getUTCDate() + 1);
|
|
144
|
+
candidate.setUTCHours(0, 0, 0, 0);
|
|
145
|
+
iterations += 60;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const hour = candidate.getUTCHours();
|
|
150
|
+
if (!hourField.matches(hour)) {
|
|
151
|
+
// Advance to next hour
|
|
152
|
+
candidate.setUTCHours(candidate.getUTCHours() + 1, 0, 0, 0);
|
|
153
|
+
iterations += 60;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const minute = candidate.getUTCMinutes();
|
|
158
|
+
if (minuteField.matches(minute)) {
|
|
159
|
+
return candidate;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
candidate.setUTCMinutes(candidate.getUTCMinutes() + 1);
|
|
163
|
+
iterations++;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
throw new Error(`Unable to find next execution for cron expression: "${cronExpr}" within search horizon.`);
|
|
167
|
+
}
|
|
@@ -99,6 +99,21 @@ export interface DatabaseClient {
|
|
|
99
99
|
*/
|
|
100
100
|
delete(table: string, where: WhereCondition): Promise<number>;
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Declarative row count with optional WHERE filter.
|
|
104
|
+
*/
|
|
105
|
+
count(table: string, where?: WhereCondition): Promise<number>;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Declarative existence check returning true if at least one matching row exists.
|
|
109
|
+
*/
|
|
110
|
+
exists(table: string, where: WhereCondition): Promise<boolean>;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Declarative aggregation and grouping query.
|
|
114
|
+
*/
|
|
115
|
+
aggregate<T = Record<string, unknown>>(table: string, options: QueryOptions): Promise<T[]>;
|
|
116
|
+
|
|
102
117
|
/**
|
|
103
118
|
* Execute command inside a database transaction boundary.
|
|
104
119
|
*/
|
|
@@ -290,6 +305,26 @@ export class MockDatabaseClient implements DatabaseClient {
|
|
|
290
305
|
return removed;
|
|
291
306
|
}
|
|
292
307
|
|
|
308
|
+
async count(table: string, where?: WhereCondition): Promise<number> {
|
|
309
|
+
const rows = await this.findMany(table, { where });
|
|
310
|
+
return rows.length;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async exists(table: string, where: WhereCondition): Promise<boolean> {
|
|
314
|
+
const rows = await this.findMany(table, { where, limit: 1 });
|
|
315
|
+
return rows.length > 0;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async aggregate<T = Record<string, unknown>>(table: string, options: QueryOptions): Promise<T[]> {
|
|
319
|
+
if (options.aggregations?.length || options.groupBy || options.having) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
'UNSUPPORTED_OPERATION: MockDatabaseClient.aggregate does not implement aggregations, grouping or HAVING; use a real database for that assertion'
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return this.findMany<T>(table, options);
|
|
326
|
+
}
|
|
327
|
+
|
|
293
328
|
async transaction<T>(operation: (tx: DatabaseClient) => Promise<T>): Promise<T> {
|
|
294
329
|
const snapshot = new Map(
|
|
295
330
|
Array.from(this.tables.entries()).map(([table, rows]) => [table, rows.map((row) => ({ ...row }))])
|
package/src/core/index.ts
CHANGED
|
@@ -2,11 +2,13 @@ export * from './action-context';
|
|
|
2
2
|
export * from './action-definition';
|
|
3
3
|
export * from './cache';
|
|
4
4
|
export * from './config';
|
|
5
|
+
export * from './cron-parser';
|
|
5
6
|
export * from './database-client';
|
|
6
7
|
export * from './database-factory';
|
|
7
8
|
export * from './image-optimizer';
|
|
8
9
|
export * from './jobs';
|
|
9
10
|
export * from './machine-types';
|
|
11
|
+
export * from './oauth-client';
|
|
10
12
|
export * from './path-guard';
|
|
11
13
|
export * from './postgres-client';
|
|
12
14
|
export * from './postgres-queue';
|