@ismaelsoilet/synapsejs 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/bin/synapse.ts +919 -0
  4. package/package.json +70 -0
  5. package/src/client/components.tsx +508 -0
  6. package/src/client/context.tsx +62 -0
  7. package/src/client/hooks.ts +152 -0
  8. package/src/client/i18n.ts +40 -0
  9. package/src/client.ts +66 -0
  10. package/src/compiler/agent-diagnostic-json.ts +113 -0
  11. package/src/compiler/ast-daemon-compressor.ts +190 -0
  12. package/src/compiler/client-bundler.ts +356 -0
  13. package/src/compiler/context-bench.ts +191 -0
  14. package/src/compiler/contract.ts +260 -0
  15. package/src/compiler/db-schema-generator.ts +306 -0
  16. package/src/compiler/fields-parser.ts +267 -0
  17. package/src/compiler/impact-analyzer.ts +279 -0
  18. package/src/compiler/migration-runner.ts +555 -0
  19. package/src/compiler/oracle-runner.ts +293 -0
  20. package/src/compiler/scaffolder.ts +528 -0
  21. package/src/compiler/schema-dag.ts +137 -0
  22. package/src/compiler/schema-drift.ts +184 -0
  23. package/src/compiler/slice-discovery.ts +154 -0
  24. package/src/compiler/slice-splitter.ts +838 -0
  25. package/src/compiler/slice-templates.ts +1527 -0
  26. package/src/compiler/standalone-builder.ts +117 -0
  27. package/src/core/action-context.ts +152 -0
  28. package/src/core/config.ts +77 -0
  29. package/src/core/database-client.ts +246 -0
  30. package/src/core/database-factory.ts +46 -0
  31. package/src/core/image-optimizer.ts +83 -0
  32. package/src/core/index.ts +14 -0
  33. package/src/core/jobs.ts +53 -0
  34. package/src/core/machine-types.ts +75 -0
  35. package/src/core/oracle.ts +20 -0
  36. package/src/core/postgres-client.ts +164 -0
  37. package/src/core/postgres-queue.ts +288 -0
  38. package/src/core/query-builder.ts +343 -0
  39. package/src/core/rpc-client.ts +70 -0
  40. package/src/core/session-context.ts +87 -0
  41. package/src/core/session-cookie.ts +81 -0
  42. package/src/core/session-token.ts +170 -0
  43. package/src/core/sqlite-client.ts +201 -0
  44. package/src/core/storage.ts +317 -0
  45. package/src/index.ts +133 -0
  46. package/src/mcp/server.ts +553 -0
  47. package/src/runtime/client-entry.ts +65 -0
  48. package/src/runtime/discovery-rules.ts +63 -0
  49. package/src/runtime/event-hub.ts +97 -0
  50. package/src/runtime/postgres-event-hub.ts +148 -0
  51. package/src/runtime/queue-engine.ts +255 -0
  52. package/src/runtime/rate-limiter.ts +152 -0
  53. package/src/runtime/server.ts +1550 -0
  54. package/src/runtime/uploads.ts +192 -0
  55. package/templates/starter/.dockerignore +7 -0
  56. package/templates/starter/Dockerfile +40 -0
  57. package/templates/starter/README.md +40 -0
  58. package/templates/starter/gitignore +6 -0
  59. package/templates/starter/package.json +34 -0
  60. package/templates/starter/src/slices/welcome/hello-world.slice.tsx +172 -0
  61. package/templates/starter/tsconfig.json +21 -0
@@ -0,0 +1,65 @@
1
+ /**
2
+ * SynapseJS - Client Entry Generator
3
+ *
4
+ * Produces the browser entry for one slice. It hydrates the same React component
5
+ * the server rendered and wires `onSubmitAction` to the slice's RPC endpoint, so
6
+ * the call site a slice author writes is valid on both sides: a direct call on the
7
+ * server, a transparent RPC in the browser.
8
+ *
9
+ * The entry imports the splitter's client artifact, never the slice itself — that
10
+ * is what keeps database code out of the browser bundle.
11
+ */
12
+
13
+ export interface ClientEntryOptions {
14
+ componentName: string;
15
+ /** Relative path (from the entry file) to the splitter's client artifact. */
16
+ clientModulePath: string;
17
+ rpcPath: string;
18
+ rootId?: string;
19
+ actionPropNames?: string[];
20
+ }
21
+
22
+ export const CLIENT_ROOT_ID = 'synapse-root';
23
+ export const CLIENT_PROPS_GLOBAL = '__SYNAPSE_PROPS__';
24
+
25
+ export function clientEntrySource(options: ClientEntryOptions): string {
26
+ const rootId = options.rootId ?? CLIENT_ROOT_ID;
27
+ const propNames = options.actionPropNames ?? ['onSubmitAction', 'action'];
28
+ const actionWires = propNames
29
+ .map((name) => ` ${name}: (payload: unknown) => rpcCall(${JSON.stringify(options.rpcPath)}, payload)`)
30
+ .join(',\n');
31
+
32
+ return [
33
+ `// [SYNAPSE-JS GENERATED CLIENT ENTRY] hydrates the slice in the browser`,
34
+ `import React from 'react';`,
35
+ `import { hydrateRoot } from 'react-dom/client';`,
36
+ `import { rpcCall } from 'synapsejs/client';`,
37
+ `import { ${options.componentName} } from ${JSON.stringify(options.clientModulePath)};`,
38
+ ``,
39
+ `export function hydrate() {`,
40
+ ` const scope = globalThis as unknown as Record<string, unknown>;`,
41
+ ` const props = (scope[${JSON.stringify(CLIENT_PROPS_GLOBAL)}] ?? {}) as Record<string, unknown>;`,
42
+ ` const root = document.getElementById(${JSON.stringify(rootId)});`,
43
+ ` if (root) {`,
44
+ ` hydrateRoot(`,
45
+ ` root,`,
46
+ ` React.createElement(${options.componentName}, {`,
47
+ ` ...props,`,
48
+ `${actionWires}`,
49
+ ` })`,
50
+ ` );`,
51
+ ` }`,
52
+ `}`,
53
+ ``,
54
+ `hydrate();`,
55
+ ``
56
+ ].join('\n');
57
+ }
58
+
59
+ /**
60
+ * Props travel to the browser as JSON inside a script tag, so `<` must be escaped
61
+ * or a value containing `</script>` would end the tag early.
62
+ */
63
+ export function serializeClientProps(props: Record<string, unknown>): string {
64
+ return JSON.stringify(props).replace(/</g, '\\u003c');
65
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * SynapseJS - Discovery Rules
3
+ *
4
+ * What a slice exports, and which suffix means what, is decided here and nowhere
5
+ * else. The runtime, the splitter and the machine contract all read these
6
+ * constants, so the contract an agent reads cannot drift from the behavior that
7
+ * enforces it — and a suffix is never guessed in two places.
8
+ */
9
+
10
+ export const ACTION_SUFFIX = 'Action';
11
+ export const JOB_SUFFIX = 'Job';
12
+ export const WEBHOOK_SUFFIX = 'Webhook';
13
+ export const LOADER_SUFFIX = 'Loader';
14
+ export const SCHEMA_SUFFIX = 'Schema';
15
+ export const META_SUFFIX = 'Meta';
16
+ export const SLICE_META_EXPORT = 'sliceMeta';
17
+ export const SLICE_DDL_EXPORT = 'sliceSchema';
18
+ export const SLICE_ORACLE_EXPORT = 'sliceTests';
19
+
20
+ /** React UI exports. */
21
+ export const COMPONENT_SUFFIXES = ['Trigger', 'View', 'Form', 'Component'] as const;
22
+
23
+ /** Everything the server side of a slice is built from. */
24
+ export const SERVER_ROOT_SUFFIXES = [ACTION_SUFFIX, JOB_SUFFIX, WEBHOOK_SUFFIX, META_SUFFIX] as const;
25
+
26
+ /** Test-only exports, dropped from both runtime bundles. */
27
+ export const TEST_ONLY_EXPORTS = [SLICE_ORACLE_EXPORT] as const;
28
+
29
+ export function hasSuffix(name: string, suffixes: readonly string[]): boolean {
30
+ return suffixes.some((suffix) => name.endsWith(suffix));
31
+ }
32
+
33
+ export function isLoader(name: string): boolean {
34
+ return name.endsWith(LOADER_SUFFIX);
35
+ }
36
+
37
+ export function isAction(name: string): boolean {
38
+ return name.endsWith(ACTION_SUFFIX);
39
+ }
40
+
41
+ export function isJob(name: string): boolean {
42
+ return name.endsWith(JOB_SUFFIX);
43
+ }
44
+
45
+ export function isWebhook(name: string): boolean {
46
+ return name.endsWith(WEBHOOK_SUFFIX);
47
+ }
48
+
49
+ export function isComponent(name: string): boolean {
50
+ return hasSuffix(name, COMPONENT_SUFFIXES);
51
+ }
52
+
53
+ export function isSchema(name: string): boolean {
54
+ return name.endsWith(SCHEMA_SUFFIX);
55
+ }
56
+
57
+ export function isMeta(name: string): boolean {
58
+ return name === SLICE_META_EXPORT || name.endsWith(META_SUFFIX);
59
+ }
60
+
61
+ export function isTestOnly(name: string): boolean {
62
+ return hasSuffix(name, TEST_ONLY_EXPORTS);
63
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * SynapseJS - Reactive Event Hub & Server-Sent Events (SSE) Engine
3
+ *
4
+ * Lightweight, in-memory pub/sub broker for reactive slice subscriptions.
5
+ * Zero external dependencies, thread-safe within the Bun process, and designed
6
+ * for real-time reactivity without stateful WebSocket overhead.
7
+ */
8
+
9
+ export type EventListener = (data: unknown) => void;
10
+ export type UnsubscribeFn = () => void;
11
+
12
+ export class EventHub {
13
+ private topics: Map<string, Set<EventListener>> = new Map();
14
+
15
+ /**
16
+ * Subscribes a listener to a specific topic.
17
+ * Returns an unsubscribe function.
18
+ */
19
+ subscribe(topic: string, listener: EventListener): UnsubscribeFn {
20
+ let listeners = this.topics.get(topic);
21
+ if (!listeners) {
22
+ listeners = new Set();
23
+ this.topics.set(topic, listeners);
24
+ }
25
+
26
+ listeners.add(listener);
27
+
28
+ return () => {
29
+ listeners?.delete(listener);
30
+ if (listeners && listeners.size === 0) {
31
+ this.topics.delete(topic);
32
+ }
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Publishes an event payload to all listeners registered for the topic.
38
+ * Returns the number of listeners that received the event.
39
+ */
40
+ publish(topic: string, data: unknown): number {
41
+ const listeners = this.topics.get(topic);
42
+ if (!listeners || listeners.size === 0) {
43
+ return 0;
44
+ }
45
+
46
+ let notified = 0;
47
+ for (const listener of listeners) {
48
+ try {
49
+ listener(data);
50
+ notified++;
51
+ } catch (err) {
52
+ console.error(`[EventHub] Erro no listener do tópico "${topic}":`, err);
53
+ }
54
+ }
55
+
56
+ return notified;
57
+ }
58
+
59
+ /**
60
+ * Returns the count of active listeners on a given topic.
61
+ */
62
+ listenerCount(topic: string): number {
63
+ return this.topics.get(topic)?.size ?? 0;
64
+ }
65
+
66
+ /**
67
+ * Returns all active topics currently subscribed to.
68
+ */
69
+ activeTopics(): string[] {
70
+ return Array.from(this.topics.keys());
71
+ }
72
+
73
+ /**
74
+ * Clears listeners for a given topic, or all topics if none specified.
75
+ */
76
+ clear(topic?: string): void {
77
+ if (topic) {
78
+ this.topics.delete(topic);
79
+ } else {
80
+ this.topics.clear();
81
+ }
82
+ }
83
+ }
84
+
85
+ let globalEventHub: EventHub | null = null;
86
+
87
+ export function getEventHub(): EventHub {
88
+ if (!globalEventHub) {
89
+ globalEventHub = new EventHub();
90
+ }
91
+ return globalEventHub;
92
+ }
93
+
94
+ export function resetEventHub(): void {
95
+ globalEventHub?.clear();
96
+ globalEventHub = null;
97
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * SynapseJS - Distributed PostgreSQL EventHub
3
+ *
4
+ * Implements a distributed pub/sub broker across multiple server instances
5
+ * using PostgreSQL LISTEN/NOTIFY with:
6
+ * 1. Dedicated persistent connection for LISTEN (max: 1) to avoid transaction pooler starvation.
7
+ * 2. Automatic payload chunking / offloading to `_synapse_event_payloads` for messages > 7.5KB (RFC 8KB pg_notify limit).
8
+ * 3. Transparent local delivery and non-blocking background broadcast.
9
+ */
10
+
11
+ import postgres from 'postgres';
12
+ import { EventHub } from './event-hub';
13
+
14
+ export interface PostgresEventHubOptions {
15
+ connectionUri: string;
16
+ channelName?: string;
17
+ onNotice?: (notice: postgres.Notice) => void;
18
+ }
19
+
20
+ export class PostgresEventHub extends EventHub {
21
+ public readonly instanceId = crypto.randomUUID();
22
+ private connectionUri: string;
23
+ private channelName: string;
24
+ private listenerClient: postgres.Sql | null = null;
25
+ private publisherClient: postgres.Sql | null = null;
26
+ private isInitialized = false;
27
+ private isClosed = false;
28
+
29
+ constructor(options: PostgresEventHubOptions) {
30
+ super();
31
+ this.connectionUri = options.connectionUri;
32
+ this.channelName = options.channelName || 'synapse_events';
33
+ }
34
+
35
+ /**
36
+ * Initializes persistent dedicated connection for LISTEN and publisher pool.
37
+ */
38
+ async init(): Promise<void> {
39
+ if (this.isInitialized || this.isClosed) return;
40
+
41
+ // 1. Dedicated persistent listener connection (max: 1, idle_timeout: 0)
42
+ this.listenerClient = postgres(this.connectionUri, {
43
+ max: 1,
44
+ idle_timeout: 0,
45
+ onnotice: () => {}
46
+ });
47
+
48
+ // 2. Publisher pool
49
+ this.publisherClient = postgres(this.connectionUri, {
50
+ max: 5,
51
+ idle_timeout: 30,
52
+ onnotice: () => {}
53
+ });
54
+
55
+ // 3. Ensure large payload table exists
56
+ await this.publisherClient.unsafe(`
57
+ CREATE TABLE IF NOT EXISTS _synapse_event_payloads (
58
+ id TEXT PRIMARY KEY,
59
+ payload TEXT NOT NULL,
60
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
61
+ );
62
+ `);
63
+
64
+ // 4. Start listening on channel
65
+ await this.listenerClient.listen(this.channelName, async (rawPayload: string) => {
66
+ try {
67
+ const envelope = JSON.parse(rawPayload);
68
+
69
+ // Deduplicate: ignore events published by this exact process instance
70
+ if (envelope.o === this.instanceId) {
71
+ return;
72
+ }
73
+
74
+ let eventData: unknown;
75
+
76
+ if (envelope.ref && this.publisherClient) {
77
+ // Offloaded large payload (>7.5KB)
78
+ const rows = await this.publisherClient.unsafe('SELECT payload FROM _synapse_event_payloads WHERE id = $1', [
79
+ envelope.ref
80
+ ]);
81
+ if (rows.length > 0) {
82
+ eventData = JSON.parse(rows[0].payload);
83
+ }
84
+ } else {
85
+ eventData = envelope.d;
86
+ }
87
+
88
+ // Deliver to in-process subscribers
89
+ super.publish(envelope.t, eventData);
90
+ } catch (err) {
91
+ console.error('[PostgresEventHub] Erro ao processar mensagem LISTEN:', err);
92
+ }
93
+ });
94
+
95
+ this.isInitialized = true;
96
+ }
97
+
98
+ /**
99
+ * Publishes event locally and broadcasts via PostgreSQL LISTEN/NOTIFY.
100
+ */
101
+ override publish(topic: string, data: unknown): number {
102
+ const localNotified = super.publish(topic, data);
103
+
104
+ if (this.publisherClient && !this.isClosed) {
105
+ const client = this.publisherClient;
106
+ const serialized = JSON.stringify(data);
107
+
108
+ if (serialized.length > 7500) {
109
+ const refId = crypto.randomUUID();
110
+ client
111
+ .unsafe('INSERT INTO _synapse_event_payloads (id, payload) VALUES ($1, $2)', [refId, serialized])
112
+ .then(() => {
113
+ // Opportunistic TTL cleanup for records older than 5 minutes
114
+ client
115
+ .unsafe("DELETE FROM _synapse_event_payloads WHERE created_at < NOW() - INTERVAL '5 minutes'")
116
+ .catch(() => {});
117
+
118
+ return client.notify(this.channelName, JSON.stringify({ t: topic, ref: refId, o: this.instanceId }));
119
+ })
120
+ .catch((err) => {
121
+ console.error('[PostgresEventHub] Erro ao persistir payload offloaded:', err);
122
+ });
123
+ } else {
124
+ client.notify(this.channelName, JSON.stringify({ t: topic, d: data, o: this.instanceId })).catch((err) => {
125
+ console.error('[PostgresEventHub] Erro no pg_notify:', err);
126
+ });
127
+ }
128
+ }
129
+
130
+ return localNotified;
131
+ }
132
+
133
+ /**
134
+ * Gracefully ends connections and frees resources.
135
+ */
136
+ async close(): Promise<void> {
137
+ this.isClosed = true;
138
+ if (this.listenerClient) {
139
+ await this.listenerClient.end();
140
+ this.listenerClient = null;
141
+ }
142
+ if (this.publisherClient) {
143
+ await this.publisherClient.end();
144
+ this.publisherClient = null;
145
+ }
146
+ this.clear();
147
+ }
148
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * SynapseJS - Isolated SQLite Queue Engine for Background Jobs
3
+ *
4
+ * Persists and coordinates background jobs inside an isolated SQLite database (.synapse/queue.sqlite),
5
+ * providing atomic claiming (WAL + RETURNING), exponential backoff retries, and dead-letter handling.
6
+ */
7
+
8
+ import { Database } from 'bun:sqlite';
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import {
12
+ AnonymousSession,
13
+ createActionContext,
14
+ createDefaultLogger,
15
+ type DatabaseClient,
16
+ type JobDefinition,
17
+ type JobEnqueueOptions,
18
+ type SessionContext,
19
+ type StructuredLogger
20
+ } from '../core';
21
+
22
+ export interface JobRecord {
23
+ id: string;
24
+ name: string;
25
+ payload: string;
26
+ status: 'pending' | 'running' | 'completed' | 'failed';
27
+ priority: number;
28
+ attempts: number;
29
+ max_attempts: number;
30
+ backoff_seconds: number;
31
+ run_at: number;
32
+ created_at: number;
33
+ updated_at: number;
34
+ last_error: string | null;
35
+ }
36
+
37
+ export interface QueueEngineOptions {
38
+ /** Path to SQLite database file or ':memory:' */
39
+ dbPath?: string;
40
+ logger?: StructuredLogger;
41
+ }
42
+
43
+ export class QueueEngine {
44
+ private db: Database;
45
+ private registry: Map<string, JobDefinition<unknown>> = new Map();
46
+ private logger: StructuredLogger;
47
+
48
+ constructor(options: QueueEngineOptions = {}) {
49
+ this.logger = options.logger || createDefaultLogger('QueueEngine');
50
+ const dbPath = options.dbPath || path.join(process.cwd(), '.synapse/queue.sqlite');
51
+
52
+ if (dbPath !== ':memory:') {
53
+ const dir = path.dirname(dbPath);
54
+ if (!fs.existsSync(dir)) {
55
+ fs.mkdirSync(dir, { recursive: true });
56
+ }
57
+ }
58
+
59
+ this.db = new Database(dbPath);
60
+ this.db.run('PRAGMA journal_mode = WAL;');
61
+ this.db.run('PRAGMA busy_timeout = 5000;');
62
+ this.initSchema();
63
+ }
64
+
65
+ get database(): Database {
66
+ return this.db;
67
+ }
68
+
69
+ private initSchema(): void {
70
+ this.db.run(`
71
+ CREATE TABLE IF NOT EXISTS _synapse_jobs (
72
+ id TEXT PRIMARY KEY,
73
+ name TEXT NOT NULL,
74
+ payload TEXT NOT NULL,
75
+ status TEXT NOT NULL,
76
+ priority INTEGER NOT NULL DEFAULT 0,
77
+ attempts INTEGER NOT NULL DEFAULT 0,
78
+ max_attempts INTEGER NOT NULL DEFAULT 3,
79
+ backoff_seconds INTEGER NOT NULL DEFAULT 5,
80
+ run_at INTEGER NOT NULL,
81
+ created_at INTEGER NOT NULL,
82
+ updated_at INTEGER NOT NULL,
83
+ last_error TEXT
84
+ );
85
+ CREATE INDEX IF NOT EXISTS idx_synapse_jobs_pending
86
+ ON _synapse_jobs (status, run_at, priority DESC);
87
+ `);
88
+ }
89
+
90
+ /**
91
+ * Registers a job definition to be processed by this queue engine.
92
+ */
93
+ registerJob<T>(job: JobDefinition<T>): void {
94
+ this.registry.set(job.name, job as JobDefinition<unknown>);
95
+ }
96
+
97
+ /**
98
+ * Enqueues a job for background processing.
99
+ */
100
+ async enqueue<TPayload>(
101
+ jobOrName: { name: string; retryLimit?: number; backoffSeconds?: number } | string,
102
+ payload: TPayload,
103
+ options?: JobEnqueueOptions
104
+ ): Promise<string> {
105
+ const id = `job_${crypto.randomUUID().replace(/-/g, '')}`;
106
+ const name = typeof jobOrName === 'string' ? jobOrName : jobOrName.name;
107
+ const maxAttempts = typeof jobOrName === 'object' && jobOrName.retryLimit ? jobOrName.retryLimit : 3;
108
+ const backoffSeconds = typeof jobOrName === 'object' && jobOrName.backoffSeconds ? jobOrName.backoffSeconds : 5;
109
+ const priority = options?.priority ?? 0;
110
+ const delaySeconds = options?.delaySeconds ?? 0;
111
+ const now = Date.now();
112
+ const runAt = now + delaySeconds * 1000;
113
+ const payloadStr = JSON.stringify(payload);
114
+
115
+ const stmt = this.db.prepare(`
116
+ INSERT INTO _synapse_jobs (
117
+ id, name, payload, status, priority, attempts, max_attempts, backoff_seconds, run_at, created_at, updated_at
118
+ ) VALUES (?1, ?2, ?3, 'pending', ?4, 0, ?5, ?6, ?7, ?8, ?8)
119
+ `);
120
+
121
+ stmt.run(id, name, payloadStr, priority, maxAttempts, backoffSeconds, runAt, now);
122
+ return id;
123
+ }
124
+
125
+ /**
126
+ * Atomically claims the next pending job eligible for execution.
127
+ */
128
+ claimNextJob(): JobRecord | null {
129
+ const now = Date.now();
130
+ const stmt = this.db.prepare(`
131
+ UPDATE _synapse_jobs
132
+ SET status = 'running', updated_at = ?1
133
+ WHERE id = (
134
+ SELECT id FROM _synapse_jobs
135
+ WHERE status = 'pending' AND run_at <= ?1
136
+ ORDER BY priority DESC, run_at ASC, created_at ASC
137
+ LIMIT 1
138
+ )
139
+ RETURNING *;
140
+ `);
141
+
142
+ const row = stmt.get(now) as JobRecord | undefined;
143
+ return row || null;
144
+ }
145
+
146
+ /**
147
+ * Processes a single claimed job. Returns true if a job was found and processed, false if queue was empty.
148
+ */
149
+ async processNextJob(deps: {
150
+ db: DatabaseClient;
151
+ session?: SessionContext;
152
+ services?: Record<string, unknown>;
153
+ logger?: StructuredLogger;
154
+ }): Promise<boolean> {
155
+ const job = this.claimNextJob();
156
+ if (!job) {
157
+ return false;
158
+ }
159
+
160
+ const jobDef = this.registry.get(job.name);
161
+ const attempts = job.attempts + 1;
162
+ const now = Date.now();
163
+
164
+ if (!jobDef) {
165
+ const errorMsg = `Nenhum manipulador registrado para o job: ${job.name}`;
166
+ this.logger.error(errorMsg);
167
+ const updateStmt = this.db.prepare(`
168
+ UPDATE _synapse_jobs
169
+ SET status = 'failed', attempts = ?1, updated_at = ?2, last_error = ?3
170
+ WHERE id = ?4
171
+ `);
172
+ updateStmt.run(attempts, now, errorMsg, job.id);
173
+ return true;
174
+ }
175
+
176
+ let parsedPayload: unknown;
177
+ try {
178
+ parsedPayload = JSON.parse(job.payload);
179
+ } catch (parseErr: unknown) {
180
+ const errorMsg = `Falha ao desserializar payload JSON do job: ${String(parseErr)}`;
181
+ const updateStmt = this.db.prepare(`
182
+ UPDATE _synapse_jobs
183
+ SET status = 'failed', attempts = ?1, updated_at = ?2, last_error = ?3
184
+ WHERE id = ?4
185
+ `);
186
+ updateStmt.run(attempts, now, errorMsg, job.id);
187
+ return true;
188
+ }
189
+
190
+ const actionCtx = createActionContext({
191
+ db: deps.db,
192
+ session: deps.session || AnonymousSession(),
193
+ services: deps.services || {},
194
+ logger: deps.logger || this.logger,
195
+ enqueue: this.enqueue.bind(this)
196
+ });
197
+
198
+ try {
199
+ await jobDef.handler(parsedPayload, actionCtx);
200
+ const completeStmt = this.db.prepare(`
201
+ UPDATE _synapse_jobs
202
+ SET status = 'completed', attempts = ?1, updated_at = ?2, last_error = NULL
203
+ WHERE id = ?3
204
+ `);
205
+ completeStmt.run(attempts, now, job.id);
206
+ return true;
207
+ } catch (handlerErr: unknown) {
208
+ const errorMsg = handlerErr instanceof Error ? handlerErr.message : String(handlerErr);
209
+ this.logger.warn(`Job ${job.name} (${job.id}) falhou na tentativa ${attempts}/${job.max_attempts}: ${errorMsg}`);
210
+
211
+ if (attempts < job.max_attempts) {
212
+ // Exponential backoff: backoff_seconds * 2^(attempts-1)
213
+ const backoffMs = job.backoff_seconds * 1000 * 2 ** (attempts - 1);
214
+ const nextRunAt = now + backoffMs;
215
+ const retryStmt = this.db.prepare(`
216
+ UPDATE _synapse_jobs
217
+ SET status = 'pending', attempts = ?1, run_at = ?2, updated_at = ?3, last_error = ?4
218
+ WHERE id = ?5
219
+ `);
220
+ retryStmt.run(attempts, nextRunAt, now, errorMsg, job.id);
221
+ } else {
222
+ const failStmt = this.db.prepare(`
223
+ UPDATE _synapse_jobs
224
+ SET status = 'failed', attempts = ?1, updated_at = ?2, last_error = ?3
225
+ WHERE id = ?4
226
+ `);
227
+ failStmt.run(attempts, now, errorMsg, job.id);
228
+ }
229
+ return true;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Returns a snapshot of job counts by status.
235
+ */
236
+ getStats(): { pending: number; running: number; completed: number; failed: number } {
237
+ const rows = this.db
238
+ .prepare(`
239
+ SELECT status, COUNT(*) as count FROM _synapse_jobs GROUP BY status
240
+ `)
241
+ .all() as Array<{ status: string; count: number }>;
242
+
243
+ const stats = { pending: 0, running: 0, completed: 0, failed: 0 };
244
+ for (const row of rows) {
245
+ if (row.status in stats) {
246
+ stats[row.status as keyof typeof stats] = row.count;
247
+ }
248
+ }
249
+ return stats;
250
+ }
251
+
252
+ close(): void {
253
+ this.db.close();
254
+ }
255
+ }