@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.
Files changed (55) hide show
  1. package/README.md +98 -6
  2. package/bin/synapse.ts +291 -241
  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 +9 -1
  13. package/src/compiler/contract.ts +31 -4
  14. package/src/compiler/db-schema-generator.ts +39 -8
  15. package/src/compiler/fields-parser.ts +28 -1
  16. package/src/compiler/impact-analyzer.ts +31 -1
  17. package/src/compiler/migration-runner.ts +10 -2
  18. package/src/compiler/optional-coverage.ts +137 -0
  19. package/src/compiler/oracle-runner.ts +18 -4
  20. package/src/compiler/scaffolder.ts +227 -9
  21. package/src/compiler/slice-discovery.ts +8 -1
  22. package/src/compiler/slice-splitter.ts +68 -6
  23. package/src/compiler/slice-templates.ts +234 -10
  24. package/src/compiler/typescript-api.ts +33 -0
  25. package/src/core/action-context.ts +101 -15
  26. package/src/core/config.ts +88 -10
  27. package/src/core/cron-parser.ts +167 -0
  28. package/src/core/database-client.ts +103 -6
  29. package/src/core/index.ts +5 -0
  30. package/src/core/jobs.ts +37 -0
  31. package/src/core/oauth-client.ts +322 -0
  32. package/src/core/path-guard.ts +105 -0
  33. package/src/core/postgres-client.ts +37 -0
  34. package/src/core/postgres-queue.ts +76 -9
  35. package/src/core/query-builder.ts +166 -34
  36. package/src/core/session-context.ts +45 -4
  37. package/src/core/session-token.ts +124 -8
  38. package/src/core/sqlite-client.ts +20 -0
  39. package/src/core/storage.ts +60 -19
  40. package/src/core/topics.ts +84 -0
  41. package/src/core/webhook-signature.ts +151 -0
  42. package/src/index.ts +2 -1
  43. package/src/mcp/server.ts +317 -218
  44. package/src/mcp/tools.ts +329 -0
  45. package/src/project-status.ts +433 -0
  46. package/src/runtime/client-entry.ts +11 -5
  47. package/src/runtime/network-guard.ts +3 -1
  48. package/src/runtime/queue-engine.ts +167 -5
  49. package/src/runtime/rate-limiter.ts +14 -4
  50. package/src/runtime/security-headers.ts +35 -0
  51. package/src/runtime/server.ts +1174 -236
  52. package/src/runtime/uploads.ts +199 -19
  53. package/src/version.ts +9 -2
  54. package/templates/starter/package.json +2 -2
  55. package/templates/starter/tsconfig.json +1 -0
@@ -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
+ }
@@ -10,6 +10,7 @@ import { getEventHub } from '../runtime/event-hub';
10
10
  import type { DatabaseClient, QueryOptions, WhereCondition } from './database-client';
11
11
  import type { SessionContext } from './session-context';
12
12
  import { getStorage, type StorageClient } from './storage';
13
+ import { declaredTopic, scopedTopic } from './topics';
13
14
 
14
15
  export interface StructuredLogger {
15
16
  info(message: string, context?: Record<string, unknown>): void;
@@ -20,6 +21,8 @@ export interface StructuredLogger {
20
21
 
21
22
  export interface JobEnqueueOptions {
22
23
  delaySeconds?: number;
24
+ /** Absolute execution timestamp in milliseconds. Mutually exclusive with delaySeconds. */
25
+ runAt?: number;
23
26
  priority?: number;
24
27
  }
25
28
 
@@ -32,8 +35,20 @@ export type EnqueueFn = <TPayload>(
32
35
  export type BroadcastFn = (topic: string, data: unknown) => number;
33
36
 
34
37
  export interface CookieOptions {
38
+ /**
39
+ * Defaults to true. A cookie written without stating otherwise is not readable by
40
+ * script; pass `false` explicitly to opt out.
41
+ */
35
42
  httpOnly?: boolean;
43
+ /**
44
+ * Defaults to true. A cookie written without stating otherwise is not sent over
45
+ * plaintext; pass `false` explicitly to opt out.
46
+ */
36
47
  secure?: boolean;
48
+ /**
49
+ * Defaults to `lax`. An explicit `strict` or `none` applies instead — the framework
50
+ * never infers a weaker policy from anything but a deliberate statement.
51
+ */
37
52
  sameSite?: 'lax' | 'strict' | 'none';
38
53
  maxAge?: number;
39
54
  path?: string;
@@ -46,6 +61,12 @@ export interface PendingCookie {
46
61
  options?: CookieOptions;
47
62
  }
48
63
 
64
+ /**
65
+ * Serializes one response cookie. The safe attributes are the **defaults**: a cookie
66
+ * written with no options at all is HttpOnly, Secure and SameSite=Lax, and only an
67
+ * explicit `false` (or a different `sameSite`) changes that. This is a breaking
68
+ * change from the previous opt-in behaviour.
69
+ */
49
70
  export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
50
71
  const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
51
72
  if (options.maxAge !== undefined) {
@@ -56,15 +77,14 @@ export function serializeCookie(name: string, value: string, options: CookieOpti
56
77
  }
57
78
  const cleanPath = options.path && !/[\r\n;\s]/.test(options.path) ? options.path.trim() : '/';
58
79
  parts.push(`Path=${cleanPath}`);
59
- if (options.secure) {
80
+ if (options.secure ?? true) {
60
81
  parts.push('Secure');
61
82
  }
62
- if (options.httpOnly) {
83
+ if (options.httpOnly ?? true) {
63
84
  parts.push('HttpOnly');
64
85
  }
65
- if (options.sameSite) {
66
- parts.push(`SameSite=${options.sameSite.charAt(0).toUpperCase() + options.sameSite.slice(1).toLowerCase()}`);
67
- }
86
+ const sameSite = options.sameSite ?? 'lax';
87
+ parts.push(`SameSite=${sameSite.charAt(0).toUpperCase() + sameSite.slice(1).toLowerCase()}`);
68
88
  return parts.join('; ');
69
89
  }
70
90
 
@@ -99,11 +119,14 @@ export interface ActionContext<TServices = Record<string, unknown>> extends Data
99
119
  /** Sets an HTTP response cookie (e.g. HttpOnly session token) */
100
120
  readonly setCookie: (name: string, value: string, options?: CookieOptions) => void;
101
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
+
102
128
  /** Internal list of pending cookies to be set on the response */
103
129
  readonly _pendingCookies?: PendingCookie[];
104
-
105
- /** Lazy adapter for Kysely (if configured or requested) */
106
- readonly kysely?: unknown;
107
130
  }
108
131
 
109
132
  export function createDefaultLogger(sliceName?: string): StructuredLogger {
@@ -130,7 +153,11 @@ export interface ActionContextOptions<TServices = Record<string, unknown>> {
130
153
  storage?: StorageClient;
131
154
  broadcast?: BroadcastFn;
132
155
  invalidateCache?: (tags?: string[]) => void;
133
- kysely?: unknown;
156
+ /**
157
+ * The slice key (`<domain>/<name>`) this context belongs to. Publishing into a
158
+ * declared topic is only allowed for the declaration's owner.
159
+ */
160
+ sliceOwner?: string;
134
161
  }
135
162
 
136
163
  /**
@@ -143,21 +170,77 @@ export function createActionContext<TServices = Record<string, unknown>>(
143
170
  const {
144
171
  db,
145
172
  session,
146
- tenantId = session.tenantId,
173
+ tenantId = session?.tenantId,
147
174
  services = {} as TServices,
148
175
  logger = createDefaultLogger(),
149
176
  enqueue = async () => 'job_noop',
150
177
  storage = getStorage(),
151
- broadcast = (topic: string, data: unknown) => getEventHub().publish(topic, data),
152
178
  invalidateCache = () => {},
153
- kysely
179
+ sliceOwner
154
180
  } = options;
155
181
 
182
+ /**
183
+ * Publishing is declared, not inferred: the topic must exist in the session's
184
+ * tenant scope and this feature must be its declared owner. Anything else is
185
+ * rejected and notifies nobody.
186
+ */
187
+ const broadcast: BroadcastFn =
188
+ options.broadcast ??
189
+ ((topic: string, data: unknown) => {
190
+ const declaration = declaredTopic(topic, tenantId);
191
+
192
+ if (!declaration || (sliceOwner && declaration.owner !== sliceOwner)) {
193
+ logger.warn(
194
+ `Broadcast recusado no tópico "${topic}": não declarado${sliceOwner ? ` por ${sliceOwner}` : ''} (defineTopic).`
195
+ );
196
+ return 0;
197
+ }
198
+
199
+ return getEventHub().publish(scopedTopic(topic, tenantId), data);
200
+ });
201
+
156
202
  const pendingCookies: PendingCookie[] = [];
157
203
  const setCookie = (name: string, value: string, options?: CookieOptions) => {
158
204
  pendingCookies.push({ name, value, options });
159
205
  };
160
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
+
161
244
  const context: ActionContext<TServices> = {
162
245
  // ActionContext properties
163
246
  db,
@@ -170,8 +253,8 @@ export function createActionContext<TServices = Record<string, unknown>>(
170
253
  broadcast,
171
254
  invalidateCache,
172
255
  setCookie,
256
+ stream,
173
257
  _pendingCookies: pendingCookies,
174
- kysely,
175
258
 
176
259
  // DatabaseClient proxy methods
177
260
  query: <T = unknown>(sql: string, params?: unknown[]) => db.query<T>(sql, params),
@@ -184,6 +267,9 @@ export function createActionContext<TServices = Record<string, unknown>>(
184
267
  update: <T = unknown>(table: string, data: Record<string, unknown>, where: WhereCondition) =>
185
268
  db.update<T>(table, data, where),
186
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),
187
273
  transaction: async <T>(operation: (tx: DatabaseClient) => Promise<T>): Promise<T> => {
188
274
  return db.transaction(async (txDb) => {
189
275
  // Create transactional ActionContext preserving services and context
@@ -195,8 +281,8 @@ export function createActionContext<TServices = Record<string, unknown>>(
195
281
  logger,
196
282
  enqueue,
197
283
  storage,
198
- broadcast,
199
- kysely: txDb
284
+ broadcast: options.broadcast,
285
+ sliceOwner
200
286
  });
201
287
  return operation(txContext);
202
288
  });