@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.
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/bin/synapse.ts +919 -0
- package/package.json +70 -0
- package/src/client/components.tsx +508 -0
- package/src/client/context.tsx +62 -0
- package/src/client/hooks.ts +152 -0
- package/src/client/i18n.ts +40 -0
- package/src/client.ts +66 -0
- package/src/compiler/agent-diagnostic-json.ts +113 -0
- package/src/compiler/ast-daemon-compressor.ts +190 -0
- package/src/compiler/client-bundler.ts +356 -0
- package/src/compiler/context-bench.ts +191 -0
- package/src/compiler/contract.ts +260 -0
- package/src/compiler/db-schema-generator.ts +306 -0
- package/src/compiler/fields-parser.ts +267 -0
- package/src/compiler/impact-analyzer.ts +279 -0
- package/src/compiler/migration-runner.ts +555 -0
- package/src/compiler/oracle-runner.ts +293 -0
- package/src/compiler/scaffolder.ts +528 -0
- package/src/compiler/schema-dag.ts +137 -0
- package/src/compiler/schema-drift.ts +184 -0
- package/src/compiler/slice-discovery.ts +154 -0
- package/src/compiler/slice-splitter.ts +838 -0
- package/src/compiler/slice-templates.ts +1527 -0
- package/src/compiler/standalone-builder.ts +117 -0
- package/src/core/action-context.ts +152 -0
- package/src/core/config.ts +77 -0
- package/src/core/database-client.ts +246 -0
- package/src/core/database-factory.ts +46 -0
- package/src/core/image-optimizer.ts +83 -0
- package/src/core/index.ts +14 -0
- package/src/core/jobs.ts +53 -0
- package/src/core/machine-types.ts +75 -0
- package/src/core/oracle.ts +20 -0
- package/src/core/postgres-client.ts +164 -0
- package/src/core/postgres-queue.ts +288 -0
- package/src/core/query-builder.ts +343 -0
- package/src/core/rpc-client.ts +70 -0
- package/src/core/session-context.ts +87 -0
- package/src/core/session-cookie.ts +81 -0
- package/src/core/session-token.ts +170 -0
- package/src/core/sqlite-client.ts +201 -0
- package/src/core/storage.ts +317 -0
- package/src/index.ts +133 -0
- package/src/mcp/server.ts +553 -0
- package/src/runtime/client-entry.ts +65 -0
- package/src/runtime/discovery-rules.ts +63 -0
- package/src/runtime/event-hub.ts +97 -0
- package/src/runtime/postgres-event-hub.ts +148 -0
- package/src/runtime/queue-engine.ts +255 -0
- package/src/runtime/rate-limiter.ts +152 -0
- package/src/runtime/server.ts +1550 -0
- package/src/runtime/uploads.ts +192 -0
- package/templates/starter/.dockerignore +7 -0
- package/templates/starter/Dockerfile +40 -0
- package/templates/starter/README.md +40 -0
- package/templates/starter/gitignore +6 -0
- package/templates/starter/package.json +34 -0
- package/templates/starter/src/slices/welcome/hello-world.slice.tsx +172 -0
- package/templates/starter/tsconfig.json +21 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - On-Demand Image Optimizer
|
|
3
|
+
*
|
|
4
|
+
* Provides dynamic, safe image resizing and format conversion.
|
|
5
|
+
* Uses dynamic import for `sharp` so Docker Alpine environments without
|
|
6
|
+
* heavy native C/C++ compilation overhead fall back gracefully to passthrough
|
|
7
|
+
* without crashing or breaking the runtime.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ImageOptimizationOptions {
|
|
11
|
+
width?: number;
|
|
12
|
+
height?: number;
|
|
13
|
+
quality?: number;
|
|
14
|
+
format?: 'webp' | 'jpeg' | 'png' | 'avif';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ImageOptimizationResult {
|
|
18
|
+
data: Uint8Array;
|
|
19
|
+
contentType: string;
|
|
20
|
+
isOptimized: boolean;
|
|
21
|
+
engine: 'sharp' | 'passthrough';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Optimizes an image buffer on-demand.
|
|
26
|
+
* If `sharp` is available in node_modules, resizes/converts formats.
|
|
27
|
+
* If `sharp` is absent (Air-Gap mode / minimal container), returns input unaltered.
|
|
28
|
+
*/
|
|
29
|
+
export async function optimizeImage(
|
|
30
|
+
input: Uint8Array | ArrayBuffer | Buffer,
|
|
31
|
+
contentType: string,
|
|
32
|
+
options: ImageOptimizationOptions = {}
|
|
33
|
+
): Promise<ImageOptimizationResult> {
|
|
34
|
+
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input as Uint8Array);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
// Dynamic import: if sharp is not installed, does not throw unhandled exception
|
|
38
|
+
const sharpPkg = 'sharp';
|
|
39
|
+
// biome-ignore lint/suspicious/noExplicitAny: optional native module boundary
|
|
40
|
+
const sharpModule: any = await import(sharpPkg).then((m) => m.default || m).catch(() => null);
|
|
41
|
+
|
|
42
|
+
if (sharpModule) {
|
|
43
|
+
let pipeline = sharpModule(buf);
|
|
44
|
+
|
|
45
|
+
if (options.width || options.height) {
|
|
46
|
+
pipeline = pipeline.resize({
|
|
47
|
+
width: options.width,
|
|
48
|
+
height: options.height,
|
|
49
|
+
fit: 'inside',
|
|
50
|
+
withoutEnlargement: true
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const targetFormat = options.format || 'webp';
|
|
55
|
+
if (targetFormat === 'webp') {
|
|
56
|
+
pipeline = pipeline.webp({ quality: options.quality || 80 });
|
|
57
|
+
} else if (targetFormat === 'jpeg') {
|
|
58
|
+
pipeline = pipeline.jpeg({ quality: options.quality || 80 });
|
|
59
|
+
} else if (targetFormat === 'png') {
|
|
60
|
+
pipeline = pipeline.png({ quality: options.quality || 80 });
|
|
61
|
+
} else if (targetFormat === 'avif') {
|
|
62
|
+
pipeline = pipeline.avif({ quality: options.quality || 80 });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const optimizedBuffer = await pipeline.toBuffer();
|
|
66
|
+
return {
|
|
67
|
+
data: new Uint8Array(optimizedBuffer),
|
|
68
|
+
contentType: `image/${targetFormat}`,
|
|
69
|
+
isOptimized: true,
|
|
70
|
+
engine: 'sharp'
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// Fall back to passthrough if native binary fails to execute
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
data: new Uint8Array(buf),
|
|
79
|
+
contentType: contentType || 'application/octet-stream',
|
|
80
|
+
isOptimized: false,
|
|
81
|
+
engine: 'passthrough'
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from './action-context';
|
|
2
|
+
export * from './config';
|
|
3
|
+
export * from './database-client';
|
|
4
|
+
export * from './database-factory';
|
|
5
|
+
export * from './image-optimizer';
|
|
6
|
+
export * from './jobs';
|
|
7
|
+
export * from './machine-types';
|
|
8
|
+
export * from './postgres-client';
|
|
9
|
+
export * from './postgres-queue';
|
|
10
|
+
export * from './query-builder';
|
|
11
|
+
export * from './session-context';
|
|
12
|
+
export * from './session-token';
|
|
13
|
+
export * from './sqlite-client';
|
|
14
|
+
export * from './storage';
|
package/src/core/jobs.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - Type-Safe Background Jobs Contract
|
|
3
|
+
*
|
|
4
|
+
* Jobs can be defined inside slices or shared modules without breaking Locality of Behavior:
|
|
5
|
+
* export const sendWelcomeEmailJob = defineJob({
|
|
6
|
+
* name: 'send-welcome-email',
|
|
7
|
+
* handler: async (payload: { email: string }, ctx) => { ... }
|
|
8
|
+
* });
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ActionContext } from './action-context';
|
|
12
|
+
|
|
13
|
+
export type JobHandlerFn<TPayload = unknown> = (payload: TPayload, ctx: ActionContext) => Promise<void>;
|
|
14
|
+
|
|
15
|
+
export interface JobDefinition<TPayload = unknown> {
|
|
16
|
+
/** Unique name identifier for the job */
|
|
17
|
+
readonly name: string;
|
|
18
|
+
/** Async execution handler receiving the typed payload and ActionContext */
|
|
19
|
+
readonly handler: JobHandlerFn<TPayload>;
|
|
20
|
+
/** Maximum retry attempts before marking the job as permanently failed (default: 3) */
|
|
21
|
+
readonly retryLimit?: number;
|
|
22
|
+
/** Initial backoff in seconds for exponential backoff retries (default: 5) */
|
|
23
|
+
readonly backoffSeconds?: number;
|
|
24
|
+
/** Optional schema validator (e.g. TypeBox schema) */
|
|
25
|
+
readonly schema?: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface JobOptions<TPayload = unknown> {
|
|
29
|
+
name: string;
|
|
30
|
+
handler?: JobHandlerFn<TPayload>;
|
|
31
|
+
perform?: JobHandlerFn<TPayload>;
|
|
32
|
+
retryLimit?: number;
|
|
33
|
+
backoffSeconds?: number;
|
|
34
|
+
schema?: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Declares a background job definition with type safety.
|
|
39
|
+
*/
|
|
40
|
+
export function defineJob<TPayload = unknown>(options: JobOptions<TPayload>): JobDefinition<TPayload> {
|
|
41
|
+
const handler = options.handler || options.perform;
|
|
42
|
+
if (!handler) {
|
|
43
|
+
throw new Error(`Job "${options.name}" must specify either a handler or perform function.`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return Object.freeze({
|
|
47
|
+
name: options.name,
|
|
48
|
+
handler,
|
|
49
|
+
retryLimit: options.retryLimit ?? 3,
|
|
50
|
+
backoffSeconds: options.backoffSeconds ?? 5,
|
|
51
|
+
schema: options.schema
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - Machine-Centric Types & Deterministic Error Modeling
|
|
3
|
+
*
|
|
4
|
+
* Eradicates hidden control flow and 'throw new Error' side effects.
|
|
5
|
+
* Forces both LLMs and compilers to explicitly map success and failure
|
|
6
|
+
* execution paths via discriminated unions.
|
|
7
|
+
*/
|
|
8
|
+
import { FormatRegistry } from '@sinclair/typebox';
|
|
9
|
+
|
|
10
|
+
// Register standard formats out-of-the-box for AI agents
|
|
11
|
+
if (!FormatRegistry.Has('email')) {
|
|
12
|
+
FormatRegistry.Set('email', (val) => typeof val === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val));
|
|
13
|
+
}
|
|
14
|
+
if (!FormatRegistry.Has('uuid')) {
|
|
15
|
+
FormatRegistry.Set(
|
|
16
|
+
'uuid',
|
|
17
|
+
(val) => typeof val === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val)
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type Ok<T> = {
|
|
22
|
+
readonly ok: true;
|
|
23
|
+
readonly value: T;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type Err<E> = {
|
|
27
|
+
readonly ok: false;
|
|
28
|
+
readonly error: E;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type Result<T, E> = Ok<T> | Err<E>;
|
|
32
|
+
|
|
33
|
+
export function Ok<T>(value: T): Ok<T> {
|
|
34
|
+
return { ok: true, value };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function Err<E>(error: E): Err<E> {
|
|
38
|
+
return { ok: false, error };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
|
|
42
|
+
return result.ok === true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isErr<T, E>(result: Result<T, E>): result is Err<E> {
|
|
46
|
+
return result.ok === false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function unwrapOr<T, E>(result: Result<T, E>, fallback: T): T {
|
|
50
|
+
return result.ok ? result.value : fallback;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function map<T, E, U>(result: Result<T, E>, fn: (val: T) => U): Result<U, E> {
|
|
54
|
+
if (result.ok) {
|
|
55
|
+
return Ok(fn(result.value));
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function mapErr<T, E, F>(result: Result<T, E>, fn: (err: E) => F): Result<T, F> {
|
|
61
|
+
if (!result.ok) {
|
|
62
|
+
return Err(fn(result.error));
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type Option<T> = { readonly hasValue: true; readonly value: T } | { readonly hasValue: false };
|
|
68
|
+
|
|
69
|
+
export function Some<T>(value: T): Option<T> {
|
|
70
|
+
return { hasValue: true, value };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function None<T = never>(): Option<T> {
|
|
74
|
+
return { hasValue: false };
|
|
75
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - Slice Oracle Contract
|
|
3
|
+
*
|
|
4
|
+
* A slice owns its invariants. The oracle is plain data + functions so it never
|
|
5
|
+
* pulls a test library into production code: the runner registers each case with
|
|
6
|
+
* Bun's real test runner from a generated companion file.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface SliceOracleCase {
|
|
10
|
+
name: string;
|
|
11
|
+
run: () => unknown | Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SliceOracle {
|
|
15
|
+
description?: string;
|
|
16
|
+
/** Preferred shape: one named case per invariant, for per-invariant reporting. */
|
|
17
|
+
cases?: SliceOracleCase[];
|
|
18
|
+
/** Legacy shape: a single unnamed routine. Reported as one case. */
|
|
19
|
+
run?: () => unknown | Promise<unknown>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - PostgreSQL Database Driver (postgres.js)
|
|
3
|
+
*
|
|
4
|
+
* Provides production-ready, pooled, high-performance PostgreSQL persistence
|
|
5
|
+
* implementing the core DatabaseClient contract.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import postgres from 'postgres';
|
|
9
|
+
import {
|
|
10
|
+
compileDelete,
|
|
11
|
+
compileInsert,
|
|
12
|
+
compileSelect,
|
|
13
|
+
compileTaggedSql,
|
|
14
|
+
compileUpdate,
|
|
15
|
+
type DatabaseClient,
|
|
16
|
+
nestJoinedRow,
|
|
17
|
+
type QueryOptions,
|
|
18
|
+
type WhereCondition
|
|
19
|
+
} from './database-client';
|
|
20
|
+
|
|
21
|
+
export class PostgresDatabaseClient implements DatabaseClient {
|
|
22
|
+
private client: postgres.Sql;
|
|
23
|
+
|
|
24
|
+
constructor(connectionString: string, options?: postgres.Options<any>) {
|
|
25
|
+
this.client = postgres(connectionString, {
|
|
26
|
+
max: options?.max || 10,
|
|
27
|
+
idle_timeout: options?.idle_timeout || 30,
|
|
28
|
+
// Notices are noise for a machine consumer: `stdout` must carry only the
|
|
29
|
+
// command's JSON result.
|
|
30
|
+
onnotice: () => {},
|
|
31
|
+
...options
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async query<T = unknown>(queryStr: string, params: unknown[] = []): Promise<T[]> {
|
|
36
|
+
// postgres.js unsafe executes raw SQL with parameter bindings
|
|
37
|
+
const result = await this.client.unsafe(queryStr, params as any[]);
|
|
38
|
+
return result as unknown as T[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async queryOne<T = unknown>(queryStr: string, params: unknown[] = []): Promise<T | null> {
|
|
42
|
+
const results = await this.query<T>(queryStr, params);
|
|
43
|
+
return results.length > 0 ? results[0] : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async sql<T = unknown>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]> {
|
|
47
|
+
const { text, params } = compileTaggedSql(strings, ...values);
|
|
48
|
+
return this.query<T>(text, params);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async sqlOne<T = unknown>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T | null> {
|
|
52
|
+
const { text, params } = compileTaggedSql(strings, ...values);
|
|
53
|
+
return this.queryOne<T>(text, params);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async findMany<T = unknown>(table: string, options?: QueryOptions): Promise<T[]> {
|
|
57
|
+
const { sql, params } = compileSelect(table, options);
|
|
58
|
+
const rows = await this.query<Record<string, unknown>>(sql, params);
|
|
59
|
+
if (options?.join && options.join.length > 0) {
|
|
60
|
+
return rows.map((r) => nestJoinedRow<T>(r, options.join));
|
|
61
|
+
}
|
|
62
|
+
return rows as unknown as T[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async findOne<T = unknown>(table: string, options?: QueryOptions): Promise<T | null> {
|
|
66
|
+
const opts = { ...options, limit: 1 };
|
|
67
|
+
const { sql, params } = compileSelect(table, opts);
|
|
68
|
+
const row = await this.queryOne<Record<string, unknown>>(sql, params);
|
|
69
|
+
if (!row) return null;
|
|
70
|
+
if (options?.join && options.join.length > 0) {
|
|
71
|
+
return nestJoinedRow<T>(row, options.join);
|
|
72
|
+
}
|
|
73
|
+
return row as unknown as T;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async insert<T = unknown>(table: string, data: Record<string, unknown>): Promise<T> {
|
|
77
|
+
const { sql, params } = compileInsert(table, data);
|
|
78
|
+
const rows = await this.query<T>(sql, params);
|
|
79
|
+
if (!rows || rows.length === 0) {
|
|
80
|
+
throw new Error(`Failed to insert into ${table}: no row returned`);
|
|
81
|
+
}
|
|
82
|
+
return rows[0];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async update<T = unknown>(table: string, data: Record<string, unknown>, where: WhereCondition): Promise<T[]> {
|
|
86
|
+
const { sql, params } = compileUpdate(table, data, where);
|
|
87
|
+
return this.query<T>(sql, params);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async delete(table: string, where: WhereCondition): Promise<number> {
|
|
91
|
+
const { sql, params } = compileDelete(table, where);
|
|
92
|
+
const rows = await this.query(sql, params);
|
|
93
|
+
return rows.length;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async transaction<T>(operation: (tx: DatabaseClient) => Promise<T>): Promise<T> {
|
|
97
|
+
const res = await this.client.begin(async (sqlTx) => {
|
|
98
|
+
const txClient: DatabaseClient = {
|
|
99
|
+
query: async <U = unknown>(q: string, p: unknown[] = []) => {
|
|
100
|
+
const res = await sqlTx.unsafe(q, p as any[]);
|
|
101
|
+
return res as unknown as U[];
|
|
102
|
+
},
|
|
103
|
+
queryOne: async <U = unknown>(q: string, p: unknown[] = []) => {
|
|
104
|
+
const res = await sqlTx.unsafe(q, p as any[]);
|
|
105
|
+
return (res[0] ?? null) as U | null;
|
|
106
|
+
},
|
|
107
|
+
sql: async <U = unknown>(strings: TemplateStringsArray, ...values: unknown[]) => {
|
|
108
|
+
const { text, params } = compileTaggedSql(strings, ...values);
|
|
109
|
+
const res = await sqlTx.unsafe(text, params as any[]);
|
|
110
|
+
return res as unknown as U[];
|
|
111
|
+
},
|
|
112
|
+
sqlOne: async <U = unknown>(strings: TemplateStringsArray, ...values: unknown[]) => {
|
|
113
|
+
const { text, params } = compileTaggedSql(strings, ...values);
|
|
114
|
+
const res = await sqlTx.unsafe(text, params as any[]);
|
|
115
|
+
return (res[0] ?? null) as U | null;
|
|
116
|
+
},
|
|
117
|
+
findMany: async <U = unknown>(table: string, options?: QueryOptions) => {
|
|
118
|
+
const { sql, params } = compileSelect(table, options);
|
|
119
|
+
const rows = await txClient.query<Record<string, unknown>>(sql, params);
|
|
120
|
+
if (options?.join && options.join.length > 0) {
|
|
121
|
+
return rows.map((r) => nestJoinedRow<U>(r, options.join));
|
|
122
|
+
}
|
|
123
|
+
return rows as unknown as U[];
|
|
124
|
+
},
|
|
125
|
+
findOne: async <U = unknown>(table: string, options?: QueryOptions) => {
|
|
126
|
+
const opts = { ...options, limit: 1 };
|
|
127
|
+
const { sql, params } = compileSelect(table, opts);
|
|
128
|
+
const row = await txClient.queryOne<Record<string, unknown>>(sql, params);
|
|
129
|
+
if (!row) return null;
|
|
130
|
+
if (options?.join && options.join.length > 0) {
|
|
131
|
+
return nestJoinedRow<U>(row, options.join);
|
|
132
|
+
}
|
|
133
|
+
return row as unknown as U;
|
|
134
|
+
},
|
|
135
|
+
insert: async <U = unknown>(table: string, data: Record<string, unknown>) => {
|
|
136
|
+
const { sql, params } = compileInsert(table, data);
|
|
137
|
+
const rows = await txClient.query<U>(sql, params);
|
|
138
|
+
if (!rows || rows.length === 0) {
|
|
139
|
+
throw new Error(`Failed to insert into ${table}: no row returned`);
|
|
140
|
+
}
|
|
141
|
+
return rows[0];
|
|
142
|
+
},
|
|
143
|
+
update: async <U = unknown>(table: string, data: Record<string, unknown>, where: WhereCondition) => {
|
|
144
|
+
const { sql, params } = compileUpdate(table, data, where);
|
|
145
|
+
return txClient.query<U>(sql, params);
|
|
146
|
+
},
|
|
147
|
+
delete: async (table: string, where: WhereCondition) => {
|
|
148
|
+
const { sql, params } = compileDelete(table, where);
|
|
149
|
+
const rows = await txClient.query(sql, params);
|
|
150
|
+
return rows.length;
|
|
151
|
+
},
|
|
152
|
+
transaction: async <U>(subOp: (nestedTx: DatabaseClient) => Promise<U>) => {
|
|
153
|
+
return await subOp(txClient);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
return await operation(txClient);
|
|
157
|
+
});
|
|
158
|
+
return res as T;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async close(): Promise<void> {
|
|
162
|
+
await this.client.end();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - Distributed PostgreSQL Queue Engine
|
|
3
|
+
*
|
|
4
|
+
* Provides cluster-safe background job processing with zero row collisions across multiple worker nodes,
|
|
5
|
+
* using PostgreSQL's native `FOR UPDATE SKIP LOCKED`, visibility timeouts for crash recovery,
|
|
6
|
+
* exponential backoff with Full Jitter, and dedicated Dead Letter Queue (DLQ).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { JobRecord } from '../runtime/queue-engine';
|
|
10
|
+
import type { DatabaseClient } from './database-client';
|
|
11
|
+
import {
|
|
12
|
+
AnonymousSession,
|
|
13
|
+
createActionContext,
|
|
14
|
+
createDefaultLogger,
|
|
15
|
+
type JobDefinition,
|
|
16
|
+
type JobEnqueueOptions,
|
|
17
|
+
type SessionContext,
|
|
18
|
+
type StructuredLogger
|
|
19
|
+
} from './index';
|
|
20
|
+
|
|
21
|
+
export interface PostgresQueueOptions {
|
|
22
|
+
db: DatabaseClient;
|
|
23
|
+
logger?: StructuredLogger;
|
|
24
|
+
visibilityTimeoutSeconds?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DlqRecord {
|
|
28
|
+
id: string;
|
|
29
|
+
job_id: string;
|
|
30
|
+
name: string;
|
|
31
|
+
payload: string;
|
|
32
|
+
attempts: number;
|
|
33
|
+
max_attempts: number;
|
|
34
|
+
last_error: string | null;
|
|
35
|
+
failed_at: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class PostgresQueueEngine {
|
|
39
|
+
private db: DatabaseClient;
|
|
40
|
+
private logger: StructuredLogger;
|
|
41
|
+
private visibilityTimeoutMs: number;
|
|
42
|
+
private registry: Map<string, JobDefinition<unknown>> = new Map();
|
|
43
|
+
private schemaInitialized = false;
|
|
44
|
+
|
|
45
|
+
constructor(options: PostgresQueueOptions) {
|
|
46
|
+
this.db = options.db;
|
|
47
|
+
this.logger = options.logger || createDefaultLogger('PostgresQueue');
|
|
48
|
+
this.visibilityTimeoutMs = (options.visibilityTimeoutSeconds ?? 300) * 1000;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async initSchema(): Promise<void> {
|
|
52
|
+
if (this.schemaInitialized) return;
|
|
53
|
+
|
|
54
|
+
await this.db.query(`
|
|
55
|
+
CREATE TABLE IF NOT EXISTS _synapse_jobs (
|
|
56
|
+
id TEXT PRIMARY KEY,
|
|
57
|
+
name TEXT NOT NULL,
|
|
58
|
+
payload TEXT NOT NULL,
|
|
59
|
+
status TEXT NOT NULL,
|
|
60
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
61
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
62
|
+
max_attempts INTEGER NOT NULL DEFAULT 3,
|
|
63
|
+
backoff_seconds INTEGER NOT NULL DEFAULT 5,
|
|
64
|
+
run_at BIGINT NOT NULL,
|
|
65
|
+
locked_at BIGINT,
|
|
66
|
+
created_at BIGINT NOT NULL,
|
|
67
|
+
updated_at BIGINT NOT NULL,
|
|
68
|
+
last_error TEXT
|
|
69
|
+
);
|
|
70
|
+
`);
|
|
71
|
+
|
|
72
|
+
await this.db.query(`
|
|
73
|
+
CREATE TABLE IF NOT EXISTS _synapse_jobs_dlq (
|
|
74
|
+
id TEXT PRIMARY KEY,
|
|
75
|
+
job_id TEXT NOT NULL,
|
|
76
|
+
name TEXT NOT NULL,
|
|
77
|
+
payload TEXT NOT NULL,
|
|
78
|
+
attempts INTEGER NOT NULL,
|
|
79
|
+
max_attempts INTEGER NOT NULL,
|
|
80
|
+
last_error TEXT,
|
|
81
|
+
failed_at BIGINT NOT NULL
|
|
82
|
+
);
|
|
83
|
+
`);
|
|
84
|
+
|
|
85
|
+
await this.db.query(`
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_synapse_jobs_pending
|
|
87
|
+
ON _synapse_jobs (status, run_at, priority DESC);
|
|
88
|
+
`);
|
|
89
|
+
|
|
90
|
+
await this.db.query(`
|
|
91
|
+
CREATE INDEX IF NOT EXISTS idx_synapse_jobs_stale
|
|
92
|
+
ON _synapse_jobs (status, locked_at);
|
|
93
|
+
`);
|
|
94
|
+
|
|
95
|
+
this.schemaInitialized = true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
registerJob<T>(job: JobDefinition<T>): void {
|
|
99
|
+
this.registry.set(job.name, job as JobDefinition<unknown>);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async enqueue<TPayload>(
|
|
103
|
+
jobOrName: { name: string; retryLimit?: number; backoffSeconds?: number } | string,
|
|
104
|
+
payload: TPayload,
|
|
105
|
+
options?: JobEnqueueOptions
|
|
106
|
+
): Promise<string> {
|
|
107
|
+
await this.initSchema();
|
|
108
|
+
|
|
109
|
+
const id = `job_${crypto.randomUUID().replace(/-/g, '')}`;
|
|
110
|
+
const name = typeof jobOrName === 'string' ? jobOrName : jobOrName.name;
|
|
111
|
+
const maxAttempts = typeof jobOrName === 'object' && jobOrName.retryLimit ? jobOrName.retryLimit : 3;
|
|
112
|
+
const backoffSeconds = typeof jobOrName === 'object' && jobOrName.backoffSeconds ? jobOrName.backoffSeconds : 5;
|
|
113
|
+
const priority = options?.priority ?? 0;
|
|
114
|
+
const delaySeconds = options?.delaySeconds ?? 0;
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
const runAt = now + delaySeconds * 1000;
|
|
117
|
+
const payloadStr = JSON.stringify(payload);
|
|
118
|
+
|
|
119
|
+
await this.db.query(
|
|
120
|
+
`INSERT INTO _synapse_jobs (
|
|
121
|
+
id, name, payload, status, priority, attempts, max_attempts, backoff_seconds, run_at, locked_at, created_at, updated_at
|
|
122
|
+
) VALUES ($1, $2, $3, 'pending', $4, 0, $5, $6, $7, NULL, $8, $8)`,
|
|
123
|
+
[id, name, payloadStr, priority, maxAttempts, backoffSeconds, runAt, now]
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return id;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Atomically claims the next pending job or recovers orphaned jobs whose worker died
|
|
131
|
+
* (locked_at older than visibilityTimeout), using PostgreSQL's FOR UPDATE SKIP LOCKED.
|
|
132
|
+
*/
|
|
133
|
+
async claimNextJob(): Promise<JobRecord | null> {
|
|
134
|
+
await this.initSchema();
|
|
135
|
+
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
const staleThreshold = now - this.visibilityTimeoutMs;
|
|
138
|
+
|
|
139
|
+
const rows = await this.db.query<JobRecord>(
|
|
140
|
+
`UPDATE _synapse_jobs
|
|
141
|
+
SET status = 'running', locked_at = $1, updated_at = $1
|
|
142
|
+
WHERE id = (
|
|
143
|
+
SELECT id FROM _synapse_jobs
|
|
144
|
+
WHERE (status = 'pending' AND run_at <= $1)
|
|
145
|
+
OR (status = 'running' AND locked_at < $2)
|
|
146
|
+
ORDER BY priority DESC, run_at ASC, created_at ASC
|
|
147
|
+
LIMIT 1
|
|
148
|
+
FOR UPDATE SKIP LOCKED
|
|
149
|
+
)
|
|
150
|
+
RETURNING *;`,
|
|
151
|
+
[now, staleThreshold]
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return rows.length > 0 ? rows[0] : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Processes a single claimed job.
|
|
159
|
+
*/
|
|
160
|
+
async processNextJob(deps: {
|
|
161
|
+
db: DatabaseClient;
|
|
162
|
+
session?: SessionContext;
|
|
163
|
+
services?: Record<string, unknown>;
|
|
164
|
+
logger?: StructuredLogger;
|
|
165
|
+
}): Promise<boolean> {
|
|
166
|
+
const job = await this.claimNextJob();
|
|
167
|
+
if (!job) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const jobDef = this.registry.get(job.name);
|
|
172
|
+
const attempts = Number(job.attempts) + 1;
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
|
|
175
|
+
if (!jobDef) {
|
|
176
|
+
const errorMsg = `Nenhum manipulador registrado para o job: ${job.name}`;
|
|
177
|
+
this.logger.error(errorMsg);
|
|
178
|
+
await this.sendToDlq(job, attempts, errorMsg, now);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let parsedPayload: unknown;
|
|
183
|
+
try {
|
|
184
|
+
parsedPayload = JSON.parse(job.payload);
|
|
185
|
+
} catch (parseErr: unknown) {
|
|
186
|
+
const errorMsg = `Falha ao desserializar payload JSON do job: ${String(parseErr)}`;
|
|
187
|
+
await this.sendToDlq(job, attempts, errorMsg, now);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const actionCtx = createActionContext({
|
|
192
|
+
db: deps.db,
|
|
193
|
+
session: deps.session || AnonymousSession(),
|
|
194
|
+
services: deps.services || {},
|
|
195
|
+
logger: deps.logger || this.logger,
|
|
196
|
+
enqueue: this.enqueue.bind(this)
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
await jobDef.handler(parsedPayload, actionCtx);
|
|
201
|
+
await this.db.query(
|
|
202
|
+
`UPDATE _synapse_jobs
|
|
203
|
+
SET status = 'completed', attempts = $1, locked_at = NULL, updated_at = $2, last_error = NULL
|
|
204
|
+
WHERE id = $3`,
|
|
205
|
+
[attempts, now, job.id]
|
|
206
|
+
);
|
|
207
|
+
return true;
|
|
208
|
+
} catch (handlerErr: unknown) {
|
|
209
|
+
const errorMsg = handlerErr instanceof Error ? handlerErr.message : String(handlerErr);
|
|
210
|
+
this.logger.warn(`Job ${job.name} (${job.id}) falhou na tentativa ${attempts}/${job.max_attempts}: ${errorMsg}`);
|
|
211
|
+
|
|
212
|
+
if (attempts < Number(job.max_attempts)) {
|
|
213
|
+
// AWS Full Jitter: distributes retries uniformly in [0, baseBackoff] to prevent thundering herd
|
|
214
|
+
const baseBackoffMs = Number(job.backoff_seconds) * 1000 * 2 ** (attempts - 1);
|
|
215
|
+
const jitteredBackoffMs = Math.floor(Math.random() * baseBackoffMs);
|
|
216
|
+
const nextRunAt = now + jitteredBackoffMs;
|
|
217
|
+
|
|
218
|
+
await this.db.query(
|
|
219
|
+
`UPDATE _synapse_jobs
|
|
220
|
+
SET status = 'pending', attempts = $1, run_at = $2, locked_at = NULL, updated_at = $3, last_error = $4
|
|
221
|
+
WHERE id = $5`,
|
|
222
|
+
[attempts, nextRunAt, now, errorMsg, job.id]
|
|
223
|
+
);
|
|
224
|
+
} else {
|
|
225
|
+
await this.sendToDlq(job, attempts, `Esgotado limite de tentativas (${job.max_attempts}): ${errorMsg}`, now);
|
|
226
|
+
}
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Routes exhausted or permanently failed jobs to the Dead Letter Queue (DLQ).
|
|
233
|
+
*/
|
|
234
|
+
private async sendToDlq(job: JobRecord, attempts: number, errorMsg: string, now: number): Promise<string> {
|
|
235
|
+
const dlqId = `dlq_${crypto.randomUUID().replace(/-/g, '')}`;
|
|
236
|
+
|
|
237
|
+
await this.db.query(
|
|
238
|
+
`INSERT INTO _synapse_jobs_dlq (
|
|
239
|
+
id, job_id, name, payload, attempts, max_attempts, last_error, failed_at
|
|
240
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
241
|
+
[dlqId, job.id, job.name, job.payload, attempts, Number(job.max_attempts), errorMsg, now]
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
await this.db.query(
|
|
245
|
+
`UPDATE _synapse_jobs
|
|
246
|
+
SET status = 'dlq', attempts = $1, locked_at = NULL, updated_at = $2, last_error = $3
|
|
247
|
+
WHERE id = $4`,
|
|
248
|
+
[attempts, now, errorMsg, job.id]
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
this.logger.warn(`[DLQ] Job ${job.name} (${job.id}) movido para Dead Letter Queue: ${dlqId}`);
|
|
252
|
+
return dlqId;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Inspects records currently sitting in the Dead Letter Queue.
|
|
257
|
+
*/
|
|
258
|
+
async getDlqJobs(limit = 50): Promise<DlqRecord[]> {
|
|
259
|
+
await this.initSchema();
|
|
260
|
+
return this.db.query<DlqRecord>(`SELECT * FROM _synapse_jobs_dlq ORDER BY failed_at DESC LIMIT $1`, [limit]);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Retries a job from the DLQ by restoring it to pending status with attempts reset.
|
|
265
|
+
*/
|
|
266
|
+
async retryDlqJob(dlqId: string): Promise<boolean> {
|
|
267
|
+
await this.initSchema();
|
|
268
|
+
const rows = await this.db.query<DlqRecord>(`SELECT * FROM _synapse_jobs_dlq WHERE id = $1`, [dlqId]);
|
|
269
|
+
|
|
270
|
+
if (rows.length === 0) return false;
|
|
271
|
+
const dlq = rows[0];
|
|
272
|
+
const now = Date.now();
|
|
273
|
+
|
|
274
|
+
await this.db.query(
|
|
275
|
+
`UPDATE _synapse_jobs
|
|
276
|
+
SET status = 'pending', attempts = 0, run_at = $1, locked_at = NULL, updated_at = $1, last_error = NULL
|
|
277
|
+
WHERE id = $2`,
|
|
278
|
+
[now, dlq.job_id]
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
await this.db.query(`DELETE FROM _synapse_jobs_dlq WHERE id = $1`, [dlqId]);
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async close(): Promise<void> {
|
|
286
|
+
// No-op for db instance managed externally
|
|
287
|
+
}
|
|
288
|
+
}
|