@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.
Files changed (38) hide show
  1. package/README.md +4 -3
  2. package/bin/synapse.ts +74 -2
  3. package/package.json +2 -2
  4. package/src/client/components.tsx +459 -1
  5. package/src/client/context.tsx +23 -3
  6. package/src/client/error-boundary.tsx +59 -0
  7. package/src/client/hooks.ts +100 -1
  8. package/src/client/toast.tsx +219 -0
  9. package/src/client/upload.ts +153 -0
  10. package/src/client.ts +37 -2
  11. package/src/compiler/client-bundler.ts +94 -2
  12. package/src/compiler/context-bench.ts +2 -0
  13. package/src/compiler/contract.ts +1 -1
  14. package/src/compiler/oracle-runner.ts +10 -1
  15. package/src/compiler/scaffolder.ts +113 -4
  16. package/src/compiler/slice-splitter.ts +21 -3
  17. package/src/compiler/slice-templates.ts +234 -10
  18. package/src/compiler/typescript-api.ts +33 -0
  19. package/src/core/action-context.ts +50 -1
  20. package/src/core/cron-parser.ts +167 -0
  21. package/src/core/database-client.ts +35 -0
  22. package/src/core/index.ts +2 -0
  23. package/src/core/jobs.ts +37 -0
  24. package/src/core/oauth-client.ts +322 -0
  25. package/src/core/postgres-client.ts +37 -0
  26. package/src/core/postgres-queue.ts +51 -2
  27. package/src/core/query-builder.ts +166 -34
  28. package/src/core/session-context.ts +45 -4
  29. package/src/core/session-token.ts +16 -1
  30. package/src/core/sqlite-client.ts +20 -0
  31. package/src/mcp/server.ts +257 -1
  32. package/src/mcp/tools.ts +92 -1
  33. package/src/project-status.ts +103 -0
  34. package/src/runtime/client-entry.ts +11 -5
  35. package/src/runtime/queue-engine.ts +120 -5
  36. package/src/runtime/security-headers.ts +35 -0
  37. package/src/runtime/server.ts +300 -58
  38. 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 = 'create' | 'list' | 'update' | 'delete' | 'login' | 'oauth-github' | 'auth-2fa';
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 id, password_hash, roles FROM users WHERE email = $1',
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 id, password_hash, roles/, () => [
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 (verified.value.userId !== 'user-1' || !verified.value.roles.includes('billing')) {
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 id, password_hash, roles/, () => [
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 id, password_hash, roles/, () => []);
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.tenantId,
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';