@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.
- package/README.md +98 -6
- package/bin/synapse.ts +291 -241
- package/package.json +2 -2
- package/src/client/components.tsx +459 -1
- package/src/client/context.tsx +23 -3
- package/src/client/error-boundary.tsx +59 -0
- package/src/client/hooks.ts +100 -1
- package/src/client/toast.tsx +219 -0
- package/src/client/upload.ts +153 -0
- package/src/client.ts +37 -2
- package/src/compiler/client-bundler.ts +94 -2
- package/src/compiler/context-bench.ts +9 -1
- package/src/compiler/contract.ts +31 -4
- package/src/compiler/db-schema-generator.ts +39 -8
- package/src/compiler/fields-parser.ts +28 -1
- package/src/compiler/impact-analyzer.ts +31 -1
- package/src/compiler/migration-runner.ts +10 -2
- package/src/compiler/optional-coverage.ts +137 -0
- package/src/compiler/oracle-runner.ts +18 -4
- package/src/compiler/scaffolder.ts +227 -9
- package/src/compiler/slice-discovery.ts +8 -1
- package/src/compiler/slice-splitter.ts +68 -6
- package/src/compiler/slice-templates.ts +234 -10
- package/src/compiler/typescript-api.ts +33 -0
- package/src/core/action-context.ts +101 -15
- package/src/core/config.ts +88 -10
- package/src/core/cron-parser.ts +167 -0
- package/src/core/database-client.ts +103 -6
- package/src/core/index.ts +5 -0
- package/src/core/jobs.ts +37 -0
- package/src/core/oauth-client.ts +322 -0
- package/src/core/path-guard.ts +105 -0
- package/src/core/postgres-client.ts +37 -0
- package/src/core/postgres-queue.ts +76 -9
- package/src/core/query-builder.ts +166 -34
- package/src/core/session-context.ts +45 -4
- package/src/core/session-token.ts +124 -8
- package/src/core/sqlite-client.ts +20 -0
- package/src/core/storage.ts +60 -19
- package/src/core/topics.ts +84 -0
- package/src/core/webhook-signature.ts +151 -0
- package/src/index.ts +2 -1
- package/src/mcp/server.ts +317 -218
- package/src/mcp/tools.ts +329 -0
- package/src/project-status.ts +433 -0
- package/src/runtime/client-entry.ts +11 -5
- package/src/runtime/network-guard.ts +3 -1
- package/src/runtime/queue-engine.ts +167 -5
- package/src/runtime/rate-limiter.ts +14 -4
- package/src/runtime/security-headers.ts +35 -0
- package/src/runtime/server.ts +1174 -236
- package/src/runtime/uploads.ts +199 -19
- package/src/version.ts +9 -2
- package/templates/starter/package.json +2 -2
- package/templates/starter/tsconfig.json +1 -0
package/src/core/config.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface SynapsePlugin {
|
|
|
11
11
|
name: string;
|
|
12
12
|
// biome-ignore lint/suspicious/noExplicitAny: generic boundary for framework server
|
|
13
13
|
onBootstrap?: (server: any) => Promise<void> | void;
|
|
14
|
-
onRequest?: (req: Request) => Promise<Response | null | undefined> | Response | null |
|
|
14
|
+
onRequest?: (req: Request) => Promise<Response | null | undefined> | Response | null | undefined;
|
|
15
15
|
onResponse?: (res: Response, req: Request) => Promise<Response> | Response;
|
|
16
16
|
// biome-ignore lint/suspicious/noExplicitAny: generic boundary for database client
|
|
17
17
|
onMigrate?: (db: any) => Promise<void> | void;
|
|
@@ -55,9 +55,31 @@ export interface SynapseConfig<TServices = Record<string, unknown>> {
|
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* Trust proxy headers (cf-connecting-ip, x-forwarded-for) for rate limiting and IP resolution.
|
|
58
|
+
* Opt-in: when this is not explicitly `true` (or SYNAPSE_TRUST_PROXY is not `true`), the
|
|
59
|
+
* immediate peer address is used and a rotating forwarding header cannot mint a new allowance.
|
|
58
60
|
*/
|
|
59
61
|
trustProxy?: boolean;
|
|
60
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Process-level maximum request body size, enforced by the HTTP server itself before any
|
|
65
|
+
* body reader runs (default: 12 MiB, above every per-route ceiling). It is the backstop
|
|
66
|
+
* behind the RPC (5 MiB), webhook (10 MiB) and upload (5 MiB) limits.
|
|
67
|
+
*/
|
|
68
|
+
maxRequestBodyBytes?: number;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Rate limiting. Scope is the serving process only: each instance keeps its own counters,
|
|
72
|
+
* so N instances behind a load balancer permit N times the per-instance allowance.
|
|
73
|
+
*/
|
|
74
|
+
rateLimit?: {
|
|
75
|
+
/** Maximum burst capacity (default: 150). */
|
|
76
|
+
capacity?: number;
|
|
77
|
+
/** Refill rate in tokens per second (default: 100). */
|
|
78
|
+
refillRate?: number;
|
|
79
|
+
/** Maximum tracked client identities before the registry is saturated (default: 10,000). */
|
|
80
|
+
maxBuckets?: number;
|
|
81
|
+
};
|
|
82
|
+
|
|
61
83
|
/**
|
|
62
84
|
* Max allowed payload bytes for RPC POST requests (default: 5MB).
|
|
63
85
|
*/
|
|
@@ -75,11 +97,54 @@ export interface SynapseConfig<TServices = Record<string, unknown>> {
|
|
|
75
97
|
maxEntries?: number;
|
|
76
98
|
};
|
|
77
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Third-party CDN and web-font origins (Tailwind's runtime compiler, Google Fonts).
|
|
102
|
+
* **Opt-in**: nothing third-party is referenced by default. When enabled, the
|
|
103
|
+
* content-security policy is rebuilt to permit exactly the configured origins, and
|
|
104
|
+
* the emitted tags carry a subresource-integrity hash — without one, the tag is not
|
|
105
|
+
* emitted at all, because an unpinned third-party script in an origin that holds a
|
|
106
|
+
* script-readable session cookie is the whole risk.
|
|
107
|
+
*/
|
|
108
|
+
cdn?: {
|
|
109
|
+
enabled?: boolean;
|
|
110
|
+
/** SRI hash for the Tailwind runtime script (`sha384-...`). */
|
|
111
|
+
scriptIntegrity?: string;
|
|
112
|
+
/** SRI hash for the Google Fonts stylesheet (`sha384-...`). */
|
|
113
|
+
fontIntegrity?: string;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Realtime (event stream and WebSocket) limits. Long-lived connections consume a
|
|
118
|
+
* token from the rate limiter at most once, so these bounds — not the token bucket —
|
|
119
|
+
* are what caps concurrent streams.
|
|
120
|
+
*/
|
|
121
|
+
realtime?: {
|
|
122
|
+
/** Maximum distinct topics with an open subscription (default: 1000). */
|
|
123
|
+
maxTopics?: number;
|
|
124
|
+
/** Maximum concurrently open realtime connections (default: 1000). */
|
|
125
|
+
maxSubscriptions?: number;
|
|
126
|
+
/** Maximum concurrently open realtime connections from one client (default: 8). */
|
|
127
|
+
maxConnectionsPerClient?: number;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Operator-supplied Content-Security-Policy. When present it is emitted as given —
|
|
132
|
+
* the framework never weakens it — and replaces the built-in policy.
|
|
133
|
+
*/
|
|
134
|
+
contentSecurityPolicy?: string;
|
|
135
|
+
|
|
78
136
|
/**
|
|
79
137
|
* Image optimizer security settings.
|
|
138
|
+
* When `allowedDomains` is absent, any publicly routable host is permitted (loopback,
|
|
139
|
+
* link-local, private and metadata addresses are always refused) and the documentation
|
|
140
|
+
* says so rather than implying a restriction that is not enforced.
|
|
80
141
|
*/
|
|
81
142
|
imageOptimizer?: {
|
|
82
143
|
allowedDomains?: string[];
|
|
144
|
+
/** Maximum bytes accepted from a remote image host (default: 10 MiB). */
|
|
145
|
+
maxResponseBytes?: number;
|
|
146
|
+
/** Maximum time a remote image fetch may take, in ms (default: 10,000). */
|
|
147
|
+
timeoutMs?: number;
|
|
83
148
|
};
|
|
84
149
|
}
|
|
85
150
|
|
|
@@ -93,20 +158,33 @@ const CONFIG_FILENAMES = ['synapse.config.ts', 'synapse.config.js', 'synapse.con
|
|
|
93
158
|
|
|
94
159
|
/**
|
|
95
160
|
* Discovers and loads `synapse.config.(ts|js|mjs)` from the application root.
|
|
161
|
+
*
|
|
162
|
+
* A configuration file that exists but cannot be loaded is **fatal**: the config
|
|
163
|
+
* carries security-relevant defaults (trusted proxy, body ceilings, realtime bounds),
|
|
164
|
+
* so degrading to an empty configuration silently would start the server with
|
|
165
|
+
* settings nobody chose. Absence of the file is not an error — it means "defaults".
|
|
96
166
|
*/
|
|
97
167
|
export async function loadSynapseConfig(appDir: string): Promise<SynapseConfig> {
|
|
98
168
|
for (const filename of CONFIG_FILENAMES) {
|
|
99
169
|
const candidate = path.join(appDir, filename);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const config = mod.default || mod;
|
|
104
|
-
return typeof config === 'function' ? await config() : config;
|
|
105
|
-
} catch (err: unknown) {
|
|
106
|
-
const errMessage = err instanceof Error ? err.message : String(err);
|
|
107
|
-
console.warn(`[SynapseConfig] Aviso ao carregar ${filename}: ${errMessage}`);
|
|
108
|
-
}
|
|
170
|
+
|
|
171
|
+
if (!fs.existsSync(candidate)) {
|
|
172
|
+
continue;
|
|
109
173
|
}
|
|
174
|
+
|
|
175
|
+
let mod: { default?: unknown } & Record<string, unknown>;
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
mod = (await import(candidate)) as { default?: unknown } & Record<string, unknown>;
|
|
179
|
+
} catch (err: unknown) {
|
|
180
|
+
const errMessage = err instanceof Error ? err.message : String(err);
|
|
181
|
+
|
|
182
|
+
throw new Error(`[SynapseConfig] falha ao carregar ${filename}: ${errMessage}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const config = (mod.default || mod) as SynapseConfig | (() => SynapseConfig | Promise<SynapseConfig>);
|
|
186
|
+
|
|
187
|
+
return typeof config === 'function' ? await config() : config;
|
|
110
188
|
}
|
|
111
189
|
|
|
112
190
|
return {};
|
|
@@ -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
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SynapseJS - Database Context & Query Builder Abstraction
|
|
3
3
|
*
|
|
4
|
-
* Provides a clean, type-safe persistence contract without reflection-based ORM bloat
|
|
5
|
-
*
|
|
4
|
+
* Provides a clean, type-safe persistence contract without reflection-based ORM bloat:
|
|
5
|
+
* raw parameterized SQL plus a relational query builder, with no adapter for any other
|
|
6
|
+
* query builder.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
export interface QueryResult<T = unknown> {
|
|
@@ -98,6 +99,21 @@ export interface DatabaseClient {
|
|
|
98
99
|
*/
|
|
99
100
|
delete(table: string, where: WhereCondition): Promise<number>;
|
|
100
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
|
+
|
|
101
117
|
/**
|
|
102
118
|
* Execute command inside a database transaction boundary.
|
|
103
119
|
*/
|
|
@@ -112,6 +128,24 @@ export interface DatabaseClient {
|
|
|
112
128
|
/**
|
|
113
129
|
* In-memory Mock Database Client for deterministic testing and PBT (Fast-Check).
|
|
114
130
|
*/
|
|
131
|
+
/** Equality and `eq`/`in` matching, which is what the in-memory store can honour honestly. */
|
|
132
|
+
function rowMatchesCondition(row: Record<string, unknown>, where: WhereCondition): boolean {
|
|
133
|
+
return Object.entries(where).every(([column, condition]) => {
|
|
134
|
+
const value = row[column];
|
|
135
|
+
|
|
136
|
+
if (condition !== null && typeof condition === 'object' && 'op' in (condition as Record<string, unknown>)) {
|
|
137
|
+
const { op, val } = condition as { op: string; val: unknown };
|
|
138
|
+
|
|
139
|
+
if (op === 'eq') return value === val;
|
|
140
|
+
if (op === 'in') return Array.isArray(val) && val.includes(value);
|
|
141
|
+
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return value === condition;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
115
149
|
export class MockDatabaseClient implements DatabaseClient {
|
|
116
150
|
private tables: Map<string, Array<Record<string, unknown>>> = new Map();
|
|
117
151
|
private queryHandlers: Array<{
|
|
@@ -228,18 +262,81 @@ export class MockDatabaseClient implements DatabaseClient {
|
|
|
228
262
|
async update<T = unknown>(table: string, data: Record<string, unknown>, where: WhereCondition): Promise<T[]> {
|
|
229
263
|
const { sql, params } = compileUpdate(table, data, where);
|
|
230
264
|
const results = await this.query<T>(sql, params);
|
|
231
|
-
|
|
232
|
-
|
|
265
|
+
|
|
266
|
+
if (results.length > 0) {
|
|
267
|
+
return results;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// No row matched, so no row is returned: reporting the payload back would be a
|
|
271
|
+
// write that never happened.
|
|
272
|
+
const rows = this.tables.get(table);
|
|
273
|
+
|
|
274
|
+
if (!rows) {
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const updated = rows.filter((row) => rowMatchesCondition(row, where));
|
|
279
|
+
for (const row of updated) {
|
|
280
|
+
Object.assign(row, data);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return updated as unknown as T[];
|
|
233
284
|
}
|
|
234
285
|
|
|
235
286
|
async delete(table: string, where: WhereCondition): Promise<number> {
|
|
236
287
|
const { sql, params } = compileDelete(table, where);
|
|
237
288
|
const results = await this.query(sql, params);
|
|
238
|
-
|
|
289
|
+
|
|
290
|
+
if (results.length > 0) {
|
|
291
|
+
return results.length;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// A delete that matched nothing removed nothing.
|
|
295
|
+
const rows = this.tables.get(table);
|
|
296
|
+
|
|
297
|
+
if (!rows) {
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const survivors = rows.filter((row) => !rowMatchesCondition(row, where));
|
|
302
|
+
const removed = rows.length - survivors.length;
|
|
303
|
+
this.tables.set(table, survivors);
|
|
304
|
+
|
|
305
|
+
return removed;
|
|
306
|
+
}
|
|
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);
|
|
239
326
|
}
|
|
240
327
|
|
|
241
328
|
async transaction<T>(operation: (tx: DatabaseClient) => Promise<T>): Promise<T> {
|
|
242
|
-
|
|
329
|
+
const snapshot = new Map(
|
|
330
|
+
Array.from(this.tables.entries()).map(([table, rows]) => [table, rows.map((row) => ({ ...row }))])
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
return await operation(this);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
// A rolled-back transaction discards the writes made inside it.
|
|
337
|
+
this.tables = snapshot;
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
243
340
|
}
|
|
244
341
|
|
|
245
342
|
close(): void {}
|
package/src/core/index.ts
CHANGED
|
@@ -2,11 +2,14 @@ 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';
|
|
12
|
+
export * from './path-guard';
|
|
10
13
|
export * from './postgres-client';
|
|
11
14
|
export * from './postgres-queue';
|
|
12
15
|
export * from './query-builder';
|
|
@@ -14,5 +17,7 @@ export * from './session-context';
|
|
|
14
17
|
export * from './session-token';
|
|
15
18
|
export * from './sqlite-client';
|
|
16
19
|
export * from './storage';
|
|
20
|
+
export * from './topics';
|
|
17
21
|
export * from './validation';
|
|
22
|
+
export * from './webhook-signature';
|
|
18
23
|
export * from './websocket';
|
package/src/core/jobs.ts
CHANGED
|
@@ -8,10 +8,35 @@
|
|
|
8
8
|
* });
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { Value } from '@sinclair/typebox/value';
|
|
11
12
|
import type { ActionContext } from './action-context';
|
|
12
13
|
|
|
13
14
|
export type JobHandlerFn<TPayload = unknown> = (payload: TPayload, ctx: ActionContext) => Promise<void>;
|
|
14
15
|
|
|
16
|
+
export type JobScheduleErrorCode = 'CRON_UNSUPPORTED_ENGINE' | 'CONFLICTING_SCHEDULE' | 'INVALID_CRON_PAYLOAD';
|
|
17
|
+
|
|
18
|
+
export class JobScheduleError extends Error {
|
|
19
|
+
readonly code: JobScheduleErrorCode;
|
|
20
|
+
|
|
21
|
+
constructor(code: JobScheduleErrorCode, message: string) {
|
|
22
|
+
super(`${code}: ${message}`);
|
|
23
|
+
this.name = 'JobScheduleError';
|
|
24
|
+
this.code = code;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function jobPayloadMatchesSchema(schema: unknown, payload: unknown): boolean {
|
|
29
|
+
if (!schema || typeof schema !== 'object') {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
return Value.Check(schema as never, payload);
|
|
35
|
+
} catch {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
15
40
|
export interface JobDefinition<TPayload = unknown> {
|
|
16
41
|
/** Unique name identifier for the job */
|
|
17
42
|
readonly name: string;
|
|
@@ -21,6 +46,12 @@ export interface JobDefinition<TPayload = unknown> {
|
|
|
21
46
|
readonly retryLimit?: number;
|
|
22
47
|
/** Initial backoff in seconds for exponential backoff retries (default: 5) */
|
|
23
48
|
readonly backoffSeconds?: number;
|
|
49
|
+
/** Optional recurring cron expression (e.g. '0 0 * * *' or hourly/daily) */
|
|
50
|
+
readonly cron?: string;
|
|
51
|
+
/** Optional payload factory used for the first run and each recurrence. */
|
|
52
|
+
readonly cronPayload?: () => TPayload;
|
|
53
|
+
/** Optional future execution timestamp in milliseconds */
|
|
54
|
+
readonly runAt?: number;
|
|
24
55
|
/** Optional schema validator (e.g. TypeBox schema) */
|
|
25
56
|
readonly schema?: unknown;
|
|
26
57
|
}
|
|
@@ -31,6 +62,9 @@ export interface JobOptions<TPayload = unknown> {
|
|
|
31
62
|
perform?: JobHandlerFn<TPayload>;
|
|
32
63
|
retryLimit?: number;
|
|
33
64
|
backoffSeconds?: number;
|
|
65
|
+
cron?: string;
|
|
66
|
+
cronPayload?: () => TPayload;
|
|
67
|
+
runAt?: number;
|
|
34
68
|
schema?: unknown;
|
|
35
69
|
}
|
|
36
70
|
|
|
@@ -48,6 +82,9 @@ export function defineJob<TPayload = unknown>(options: JobOptions<TPayload>): Jo
|
|
|
48
82
|
handler,
|
|
49
83
|
retryLimit: options.retryLimit ?? 3,
|
|
50
84
|
backoffSeconds: options.backoffSeconds ?? 5,
|
|
85
|
+
cron: options.cron,
|
|
86
|
+
cronPayload: options.cronPayload,
|
|
87
|
+
runAt: options.runAt,
|
|
51
88
|
schema: options.schema
|
|
52
89
|
});
|
|
53
90
|
}
|