@maestro-js/agent-skills 1.0.0-alpha.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/dist/choosing-an-npm-package/SKILL.md +72 -0
- package/dist/maestro-cli/SKILL.md +257 -0
- package/dist/maestro-packages/SKILL.md +42 -0
- package/dist/maestro-packages/broadcast.md +306 -0
- package/dist/maestro-packages/cache-control.md +337 -0
- package/dist/maestro-packages/cache.md +397 -0
- package/dist/maestro-packages/config.md +203 -0
- package/dist/maestro-packages/context.md +365 -0
- package/dist/maestro-packages/crypt.md +253 -0
- package/dist/maestro-packages/db.md +391 -0
- package/dist/maestro-packages/events.md +295 -0
- package/dist/maestro-packages/exceptions.md +231 -0
- package/dist/maestro-packages/form.md +391 -0
- package/dist/maestro-packages/hash.md +248 -0
- package/dist/maestro-packages/helpers.md +249 -0
- package/dist/maestro-packages/log.md +321 -0
- package/dist/maestro-packages/mail.md +365 -0
- package/dist/maestro-packages/metrics.md +236 -0
- package/dist/maestro-packages/queue.md +284 -0
- package/dist/maestro-packages/rate-limiting.md +206 -0
- package/dist/maestro-packages/react-router-file-routes.md +337 -0
- package/dist/maestro-packages/recurring-jobs.md +353 -0
- package/dist/maestro-packages/sql.md +316 -0
- package/dist/maestro-packages/tracing.md +240 -0
- package/package.json +33 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: recurring-jobs
|
|
3
|
+
description: "Use when working with @maestro-js/recurring-jobs. Cron-based job scheduling backed by the Queue package. Covers creating recurring jobs, managing schedules, start/stop processing, rescheduling, listing jobs, and inspecting upcoming runs. Follows the Provider pattern with ServiceRegistry. Key capabilities include cron expression parsing, deduplication across process restarts, per-job dedicated queues, structured log events, and paginated execution history."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# RecurringJobs Skill
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
RecurringJobs provides cron-based job scheduling backed by the Queue package. Each recurring job gets its own dedicated queue where every execution is a persisted message with full lifecycle tracking. The next run is enqueued before the work function executes, guaranteeing the schedule continues even if the current run fails.
|
|
11
|
+
|
|
12
|
+
## Quick Setup
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { RecurringJobs } from '@maestro-js/recurring-jobs'
|
|
16
|
+
import { Queue } from '@maestro-js/queue'
|
|
17
|
+
import { Log } from '@maestro-js/log'
|
|
18
|
+
|
|
19
|
+
// Create a logger for recurring job events
|
|
20
|
+
const logger = Log.create<[RecurringJobs.LogMessage]>({
|
|
21
|
+
transports: [{ write: ({ data: [msg] }) => console.log(msg) }]
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// Create and register the recurring jobs service with a queue service
|
|
25
|
+
const recurringJobsService = RecurringJobs.Provider.create({
|
|
26
|
+
logger,
|
|
27
|
+
queueService: Queue.provider('default')
|
|
28
|
+
})
|
|
29
|
+
RecurringJobs.Provider.register('default', recurringJobsService)
|
|
30
|
+
|
|
31
|
+
// Define a recurring job
|
|
32
|
+
const dailyReport = RecurringJobs.createJob({
|
|
33
|
+
name: 'daily-sales-report',
|
|
34
|
+
cron: '0 6 * * *',
|
|
35
|
+
workFn: async () => {
|
|
36
|
+
const total = await calculateDailySales()
|
|
37
|
+
return { total }
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Start processing
|
|
42
|
+
dailyReport.startProcessing()
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Cron Expression Format
|
|
46
|
+
|
|
47
|
+
Uses the `cron-parser` library (v4.x) for standard 5-field cron expressions:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
┌───────────── minute (0-59)
|
|
51
|
+
│ ┌───────────── hour (0-23)
|
|
52
|
+
│ │ ┌───────────── day of month (1-31)
|
|
53
|
+
│ │ │ ┌───────────── month (1-12)
|
|
54
|
+
│ │ │ │ ┌───────────── day of week (0-7, 0 and 7 are Sunday)
|
|
55
|
+
│ │ │ │ │
|
|
56
|
+
* * * * *
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Common examples:
|
|
60
|
+
- `0 6 * * *` -- every day at 6:00 AM
|
|
61
|
+
- `*/15 * * * *` -- every 15 minutes
|
|
62
|
+
- `0 0 * * 0` -- every Sunday at midnight
|
|
63
|
+
- `0 9 1 * *` -- first day of every month at 9:00 AM
|
|
64
|
+
- `30 */2 * * *` -- every 2 hours at the 30-minute mark
|
|
65
|
+
|
|
66
|
+
Invalid cron expressions throw immediately when passed to `createJob()`, since `cron-parser.parseExpression()` is called at creation time.
|
|
67
|
+
|
|
68
|
+
## API Reference
|
|
69
|
+
|
|
70
|
+
### Provider Setup
|
|
71
|
+
|
|
72
|
+
#### `RecurringJobs.Provider.create(config)`
|
|
73
|
+
|
|
74
|
+
Create a recurring jobs service instance.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
RecurringJobs.Provider.create(config: {
|
|
78
|
+
logger: Log.Logger<[RecurringJobs.LogMessage]>
|
|
79
|
+
queueService: Queue.QueueService
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- `logger` -- a Log instance typed to `RecurringJobs.LogMessage` for structured job lifecycle events
|
|
84
|
+
- `queueService` -- the Queue service used to back each recurring job's dedicated queue
|
|
85
|
+
|
|
86
|
+
#### `RecurringJobs.Provider.register(name, service)`
|
|
87
|
+
|
|
88
|
+
Register the service instance in the named registry.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
RecurringJobs.Provider.register('default', recurringJobsService)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
#### `RecurringJobs.provider(key)`
|
|
95
|
+
|
|
96
|
+
Resolve a named instance from the registry. Supports deferred resolution via Proxy.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
const jobs = RecurringJobs.provider('default')
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Service Methods (on the facade or provider instance)
|
|
103
|
+
|
|
104
|
+
#### `RecurringJobs.createJob(config)`
|
|
105
|
+
|
|
106
|
+
Create a recurring job with a dedicated queue. Returns a `RecurringJob` handle.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
RecurringJobs.createJob<T>(config: {
|
|
110
|
+
name: string
|
|
111
|
+
cron: string
|
|
112
|
+
workFn: () => Promise<T>
|
|
113
|
+
logger?: Log.LogFunctions<[RecurringJobs.LogMessage]> // optional per-job logger
|
|
114
|
+
}): RecurringJobs.RecurringJob<T>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Each job creates a queue named `RecurringJobs: <name>`. The cron expression is validated immediately and throws if invalid.
|
|
118
|
+
|
|
119
|
+
#### `RecurringJobs.startProcessing()`
|
|
120
|
+
|
|
121
|
+
Start processing on all registered recurring jobs.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
RecurringJobs.startProcessing()
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
#### `RecurringJobs.stopProcessing()`
|
|
128
|
+
|
|
129
|
+
Stop processing on all registered recurring jobs.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
RecurringJobs.stopProcessing()
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
#### `RecurringJobs.list()`
|
|
136
|
+
|
|
137
|
+
Return the names of all registered recurring jobs.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
RecurringJobs.list() // ['daily-sales-report', 'session-cleanup']
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
#### `RecurringJobs.get(name)`
|
|
144
|
+
|
|
145
|
+
Return the recurring job with the given name, or `undefined` if not found.
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const job = RecurringJobs.get<{ total: number }>('daily-sales-report')
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### RecurringJob Instance Methods
|
|
152
|
+
|
|
153
|
+
#### `job.startProcessing()`
|
|
154
|
+
|
|
155
|
+
Enqueue the next scheduled run and begin polling. If a waiting job already exists, it is not duplicated (deduplication).
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
dailyReport.startProcessing()
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
#### `job.stopProcessing()`
|
|
162
|
+
|
|
163
|
+
Stop polling for this job.
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
dailyReport.stopProcessing()
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
#### `job.getNextUp()`
|
|
170
|
+
|
|
171
|
+
Return the first message in `waiting` status, or `null` if none exists.
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
const next = await dailyReport.getNextUp()
|
|
175
|
+
// { id: 42, status: 'waiting', scheduledDate: '2026-02-22T06:00:00.000Z', cron: '0 6 * * *', ... }
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
#### `job.list({ pageSize, pageNumber })`
|
|
179
|
+
|
|
180
|
+
Return a paginated list of all messages (past runs and pending) for this job.
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
const history = await dailyReport.list({ pageSize: 20, pageNumber: 0 })
|
|
184
|
+
// [{ id: 41, status: 'success', result: { total: 15420 }, ... }, ...]
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
#### `job.rescheduleJob({ jobId, scheduledDate })`
|
|
188
|
+
|
|
189
|
+
Change the scheduled time of a waiting job. Only the next waiting job can be rescheduled; throws if `jobId` does not match the current waiting job.
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
await dailyReport.rescheduleJob({
|
|
193
|
+
jobId: 42,
|
|
194
|
+
scheduledDate: '2026-02-22T08:00:00.000Z' as Iso.Instant
|
|
195
|
+
})
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
#### `job.name` / `job.cron`
|
|
199
|
+
|
|
200
|
+
Read-only properties exposing the job's name and cron expression.
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
dailyReport.name // 'daily-sales-report'
|
|
204
|
+
dailyReport.cron // '0 6 * * *'
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### JobMessage Interface
|
|
208
|
+
|
|
209
|
+
Each queue message returned by `getNextUp()` and `list()` has these fields:
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
interface JobMessage<Result = any> {
|
|
213
|
+
id: number
|
|
214
|
+
batchId: number | null
|
|
215
|
+
queue: string
|
|
216
|
+
scheduledDate: Iso.Instant
|
|
217
|
+
enqueuedDate: Iso.Instant
|
|
218
|
+
startedDate: Iso.Instant | null
|
|
219
|
+
status: 'waiting' | 'processing' | 'success' | 'error' | 'stuck'
|
|
220
|
+
result: Result
|
|
221
|
+
recovered: number
|
|
222
|
+
runningTime: number | null
|
|
223
|
+
uniqueKey: number | null
|
|
224
|
+
priority: number | null
|
|
225
|
+
cron: string
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Common Patterns
|
|
230
|
+
|
|
231
|
+
### Multiple Recurring Jobs
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
const report = RecurringJobs.createJob({
|
|
235
|
+
name: 'daily-report',
|
|
236
|
+
cron: '0 6 * * *',
|
|
237
|
+
workFn: async () => generateReport()
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
const cleanup = RecurringJobs.createJob({
|
|
241
|
+
name: 'session-cleanup',
|
|
242
|
+
cron: '0 0 * * *',
|
|
243
|
+
workFn: async () => cleanStaleSessions()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// Start all jobs at once
|
|
247
|
+
RecurringJobs.startProcessing()
|
|
248
|
+
|
|
249
|
+
// Or start individually
|
|
250
|
+
report.startProcessing()
|
|
251
|
+
cleanup.startProcessing()
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Inspecting and Rescheduling
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
const job = RecurringJobs.get('daily-report')
|
|
258
|
+
if (job) {
|
|
259
|
+
const next = await job.getNextUp()
|
|
260
|
+
if (next) {
|
|
261
|
+
// Delay the next run by 2 hours
|
|
262
|
+
const delayed = new Date(new Date(next.scheduledDate).getTime() + 2 * 60 * 60 * 1000)
|
|
263
|
+
await job.rescheduleJob({
|
|
264
|
+
jobId: next.id,
|
|
265
|
+
scheduledDate: delayed.toISOString() as Iso.Instant
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Per-Job Logger
|
|
272
|
+
|
|
273
|
+
Add a dedicated logger to a specific job for fine-grained observability:
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
const jobLogger: Log.LogFunctions<[RecurringJobs.LogMessage]> = {
|
|
277
|
+
write({ data: [msg] }) {
|
|
278
|
+
if (msg.event === 'jobFailed') {
|
|
279
|
+
alertOpsTeam(msg.job, msg.error)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const criticalJob = RecurringJobs.createJob({
|
|
285
|
+
name: 'payment-reconciliation',
|
|
286
|
+
cron: '0 */4 * * *',
|
|
287
|
+
workFn: async () => reconcilePayments(),
|
|
288
|
+
logger: jobLogger
|
|
289
|
+
})
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Execution History
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
const job = RecurringJobs.get('daily-report')
|
|
296
|
+
if (job) {
|
|
297
|
+
const runs = await job.list({ pageSize: 50, pageNumber: 0 })
|
|
298
|
+
const failures = runs.filter(r => r.status === 'error')
|
|
299
|
+
const avgRuntime = runs
|
|
300
|
+
.filter(r => r.runningTime !== null)
|
|
301
|
+
.reduce((sum, r) => sum + r.runningTime!, 0) / runs.length
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## Log Events
|
|
306
|
+
|
|
307
|
+
The service emits structured log events of type `RecurringJobs.LogMessage`:
|
|
308
|
+
|
|
309
|
+
| Event | Level | Description |
|
|
310
|
+
|---|---|---|
|
|
311
|
+
| `jobProcessing` | info | A job message was dequeued and is about to execute |
|
|
312
|
+
| `jobSucceeded` | info | The work function completed successfully |
|
|
313
|
+
| `jobFailed` | error | The work function threw an error |
|
|
314
|
+
| `jobScheduled` | info | The next run was enqueued with its scheduled date |
|
|
315
|
+
| `jobAlreadyScheduled` | info | A waiting job already exists; skip enqueueing |
|
|
316
|
+
| `jobSchedulingFailed` | error | Failed to enqueue the next run |
|
|
317
|
+
|
|
318
|
+
## Cross-Package Integration
|
|
319
|
+
|
|
320
|
+
### Dependencies
|
|
321
|
+
|
|
322
|
+
- **@maestro-js/service-registry** -- foundation for the Provider pattern (registry, deferred Proxy resolution)
|
|
323
|
+
- **@maestro-js/queue** -- each recurring job creates a dedicated queue via `queueService.create()`. Queue handles message persistence, polling, dequeuing, and lifecycle transitions
|
|
324
|
+
- **@maestro-js/log** -- structured logging with pluggable transports for job lifecycle events
|
|
325
|
+
|
|
326
|
+
### How It Connects
|
|
327
|
+
|
|
328
|
+
RecurringJobs wraps Queue to add cron scheduling semantics. When `startProcessing()` is called, the next cron-derived date is computed and a queue message is enqueued for that date. When the Queue dequeues and runs the message, the work function fires and the next run is enqueued before the work executes. This ensures schedule continuity even on failure.
|
|
329
|
+
|
|
330
|
+
Queue itself depends on Db (MySQL) for message persistence, so the full dependency chain is:
|
|
331
|
+
|
|
332
|
+
```
|
|
333
|
+
RecurringJobs -> Queue -> Db (MySQL)
|
|
334
|
+
-> Log
|
|
335
|
+
-> ServiceRegistry
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### Provider Pattern
|
|
339
|
+
|
|
340
|
+
This package follows the standard maestro Provider pattern:
|
|
341
|
+
|
|
342
|
+
1. `RecurringJobs.Provider.create(config)` -- factory that instantiates the service
|
|
343
|
+
2. `RecurringJobs.Provider.register(name, service)` -- registers the instance by name
|
|
344
|
+
3. `RecurringJobs.provider(key)` -- resolves a named instance (deferred via Proxy)
|
|
345
|
+
4. `RecurringJobs.*` -- facade that spreads `provider('default')` for direct method access
|
|
346
|
+
|
|
347
|
+
## Notes
|
|
348
|
+
|
|
349
|
+
- No tests exist for this package yet (`pnpm --filter @maestro-js/recurring-jobs test` prints "No tests yet").
|
|
350
|
+
- The `cron-parser` library validates cron expressions at job creation time; invalid expressions throw immediately.
|
|
351
|
+
- Deduplication: `startProcessing()` checks for an existing waiting message before enqueueing, preventing double-fires across process restarts.
|
|
352
|
+
- The next run is enqueued inside the queue's `workFn` (before the user's `workFn` runs), so the schedule continues even if the current execution fails.
|
|
353
|
+
- Only the next waiting job can be rescheduled via `rescheduleJob()`. Attempting to reschedule a non-waiting or non-next job throws an error.
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sql
|
|
3
|
+
description: "Composable SQL query builders for dynamic WHERE clauses and Common Table Expressions (CTEs). Use when working with @maestro-js/sql, building dynamic SQL filters, constructing parameterized WHERE clauses, wrapping Db services with CTEs, or generating safe parameterized SQL fragments. Key capabilities: WhereClauseBuilder for incremental AND/OR conditions with nested sub-groups, CommonTableExpressionBuilder for wrapping a Db service so all queries auto-prepend WITH clauses, cloning builders, recursive CTEs, and dynamic parameter resolution."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @maestro-js/sql
|
|
7
|
+
|
|
8
|
+
Composable SQL query builders for constructing parameterized WHERE clauses and Common Table Expressions (CTEs). Standalone utility package with no runtime dependencies on other maestro packages.
|
|
9
|
+
|
|
10
|
+
## Quick Setup
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { Sql } from '@maestro-js/sql'
|
|
14
|
+
|
|
15
|
+
// Dynamic WHERE clause
|
|
16
|
+
const where = Sql.where()
|
|
17
|
+
where.and('status = ?', ['active'])
|
|
18
|
+
where.and('price > ?', [50])
|
|
19
|
+
await Db.select(`SELECT * FROM products WHERE ${where.query}`, where.parameters)
|
|
20
|
+
|
|
21
|
+
// CTE wrapping a Db service
|
|
22
|
+
const withTotals = Sql.cte({
|
|
23
|
+
orderTotals: { subquery: '(SELECT userId, SUM(amount) as total FROM orders GROUP BY userId)', params: [] }
|
|
24
|
+
}, Db)
|
|
25
|
+
await withTotals.select('SELECT * FROM orderTotals WHERE total > ?', [500])
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## WHERE Clause Builder
|
|
29
|
+
|
|
30
|
+
Build parameterized SQL WHERE clauses incrementally with `Sql.where()`. Conditions are appended via `and()` and `or()`. If no conditions are added, `query` returns `'TRUE'`.
|
|
31
|
+
|
|
32
|
+
### Basic Conditions
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
const where = Sql.where()
|
|
36
|
+
where.and('status = ?', ['active'])
|
|
37
|
+
where.and('created_at > ?', ['2024-01-01'])
|
|
38
|
+
// where.query => 'status = ? AND created_at > ?'
|
|
39
|
+
// where.parameters => ['active', '2024-01-01']
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Mixed AND/OR
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
const where = Sql.where()
|
|
46
|
+
where.and('a = ?', [1])
|
|
47
|
+
where.or('b = ?', [2])
|
|
48
|
+
where.and('c = ?', [3])
|
|
49
|
+
// where.query => 'a = ? OR b = ? AND c = ?'
|
|
50
|
+
// where.parameters => [1, 2, 3]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Nested Groups (Parenthesized Sub-Conditions)
|
|
54
|
+
|
|
55
|
+
Pass a callback to `and()` or `or()` to create parenthesized groups using a sub-builder:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const where = Sql.where()
|
|
59
|
+
where.and('status = ?', ['active'])
|
|
60
|
+
where.and(sub => {
|
|
61
|
+
sub.or('role = ?', ['admin'])
|
|
62
|
+
sub.or('role = ?', ['editor'])
|
|
63
|
+
})
|
|
64
|
+
// where.query => 'status = ? AND (role = ? OR role = ?)'
|
|
65
|
+
// where.parameters => ['active', 'admin', 'editor']
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Deeply Nested Groups
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
const where = Sql.where()
|
|
72
|
+
where.and('a = ?', [1])
|
|
73
|
+
where.or(qb => {
|
|
74
|
+
qb.and('b = ?', [2])
|
|
75
|
+
qb.or(iqb => {
|
|
76
|
+
iqb.and('c = ?', [3])
|
|
77
|
+
iqb.and('d = ?', [4])
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
// where.query => 'a = ? OR (b = ? OR (c = ? AND d = ?))'
|
|
81
|
+
// where.parameters => [1, 2, 3, 4]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### IN Clauses
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
const where = Sql.where()
|
|
88
|
+
where.and('id IN (?)', [[1, 2, 3]])
|
|
89
|
+
// where.query => 'id IN (?)'
|
|
90
|
+
// where.parameters => [[1, 2, 3]]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Cloning
|
|
94
|
+
|
|
95
|
+
Pass an existing builder to `Sql.where()` to clone it. The clone is independent of the original:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
const original = Sql.where()
|
|
99
|
+
original.and('a = ?', [1])
|
|
100
|
+
|
|
101
|
+
const clone = Sql.where(original)
|
|
102
|
+
clone.and('b = ?', [2])
|
|
103
|
+
|
|
104
|
+
// original.query => 'a = ?'
|
|
105
|
+
// clone.query => 'a = ? AND b = ?'
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Empty Sub-Builder
|
|
109
|
+
|
|
110
|
+
If a callback adds no conditions, the group is silently skipped:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const where = Sql.where()
|
|
114
|
+
where.and('a = ?', [1])
|
|
115
|
+
where.and(sub => {
|
|
116
|
+
// no conditions added
|
|
117
|
+
})
|
|
118
|
+
// where.query => 'a = ?'
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## CTE Builder (Common Table Expressions)
|
|
122
|
+
|
|
123
|
+
Wrap a Db service with `Sql.cte()` so every query method auto-prepends the `WITH` clause and merges CTE parameters before the query parameters.
|
|
124
|
+
|
|
125
|
+
### Basic CTE
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const withStats = Sql.cte({
|
|
129
|
+
userStats: {
|
|
130
|
+
subquery: '(SELECT userId, COUNT(*) as total FROM orders GROUP BY userId)',
|
|
131
|
+
params: []
|
|
132
|
+
}
|
|
133
|
+
}, Db)
|
|
134
|
+
|
|
135
|
+
const results = await withStats.select('SELECT * FROM userStats WHERE total > ?', [10])
|
|
136
|
+
// Executes: WITH userStats AS (SELECT userId, COUNT(*) ...) SELECT * FROM userStats WHERE total > ?
|
|
137
|
+
// Parameters: [10]
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Multiple CTEs
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
const builder = Sql.cte({
|
|
144
|
+
activeUsers: {
|
|
145
|
+
subquery: '(SELECT * FROM users WHERE active = ?)',
|
|
146
|
+
params: [true]
|
|
147
|
+
},
|
|
148
|
+
recentOrders: {
|
|
149
|
+
subquery: '(SELECT * FROM orders WHERE created_at > ?)',
|
|
150
|
+
params: ['2024-01-01']
|
|
151
|
+
}
|
|
152
|
+
}, Db)
|
|
153
|
+
|
|
154
|
+
await builder.select('SELECT * FROM activeUsers JOIN recentOrders ON activeUsers.id = recentOrders.userId')
|
|
155
|
+
// WITH activeUsers AS (...), recentOrders AS (...) SELECT ...
|
|
156
|
+
// Parameters: [true, '2024-01-01']
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Recursive CTEs
|
|
160
|
+
|
|
161
|
+
Set `recursive: true` on any expression to trigger `WITH RECURSIVE`:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
const builder = Sql.cte({
|
|
165
|
+
hierarchy: {
|
|
166
|
+
subquery: '(SELECT id, parentId, name FROM categories WHERE parentId IS NULL UNION ALL SELECT c.id, c.parentId, c.name FROM categories c JOIN hierarchy h ON c.parentId = h.id)',
|
|
167
|
+
params: [],
|
|
168
|
+
recursive: true
|
|
169
|
+
}
|
|
170
|
+
}, Db)
|
|
171
|
+
// WITH RECURSIVE hierarchy AS (...)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Dynamic Parameters
|
|
175
|
+
|
|
176
|
+
Pass a function for `params` to resolve values at query time:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
const builder = Sql.cte({
|
|
180
|
+
filtered: {
|
|
181
|
+
subquery: '(SELECT * FROM items WHERE tenant_id = ?)',
|
|
182
|
+
params: () => [getCurrentTenantId()]
|
|
183
|
+
}
|
|
184
|
+
}, Db)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Re-Wrapping with fromDbService
|
|
188
|
+
|
|
189
|
+
Use `fromDbService()` to apply the same CTEs to a different Db service (e.g., inside a transaction):
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
const cteBuilder = Sql.cte({ ... }, Db)
|
|
193
|
+
const txResult = await Db.transaction(async () => {
|
|
194
|
+
const txCte = cteBuilder.fromDbService(TxDb)
|
|
195
|
+
return txCte.select('SELECT * FROM ...')
|
|
196
|
+
})
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Wrapped Methods
|
|
200
|
+
|
|
201
|
+
The CTE builder exposes all standard Db methods: `select`, `selectIterable`, `insert`, `update`, `delete`, `statement`, `scalar`, `format`, `transaction`.
|
|
202
|
+
|
|
203
|
+
## API Reference
|
|
204
|
+
|
|
205
|
+
### Sql.where(existing?)
|
|
206
|
+
|
|
207
|
+
Create a new WhereClauseBuilder. Pass an existing builder to clone it.
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
function WhereClauseBuilder(existing?: WhereClauseBuilder): WhereClauseBuilder
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
**WhereClauseBuilder interface:**
|
|
214
|
+
|
|
215
|
+
| Member | Type | Description |
|
|
216
|
+
|--------------|--------------------------------------------------------|----------------------------------------------------------|
|
|
217
|
+
| `and()` | `(condition: string \| Callback, params?: any[]) => void` | Append condition with AND, or parenthesized group via callback |
|
|
218
|
+
| `or()` | `(condition: string \| Callback, params?: any[]) => void` | Append condition with OR, or parenthesized group via callback |
|
|
219
|
+
| `query` | `string` (readonly getter) | Assembled clause string, or `'TRUE'` if empty |
|
|
220
|
+
| `parameters` | `any[]` (readonly getter) | Parameter array matching `?` placeholders in query |
|
|
221
|
+
|
|
222
|
+
### Sql.cte(expressions, db)
|
|
223
|
+
|
|
224
|
+
Create a CTE builder wrapping a Db service.
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
function CommonTableExpressionBuilder(
|
|
228
|
+
commonTableExpressions: CommonTableExpressionSet,
|
|
229
|
+
db: CteDbService
|
|
230
|
+
): CteDbService & { fromDbService(db: CteDbService): CteDbService }
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**CommonTableExpressionSet:**
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
interface CommonTableExpressionSet {
|
|
237
|
+
[name: string]: {
|
|
238
|
+
subquery: string // CTE body wrapped in parentheses
|
|
239
|
+
params?: any[] | (() => any[]) // Static array or dynamic resolver
|
|
240
|
+
recursive?: boolean // Triggers WITH RECURSIVE
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Type Exports
|
|
246
|
+
|
|
247
|
+
Access types through the `Sql` namespace:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
Sql.cte.Expression // CommonTableExpressionConfig
|
|
251
|
+
Sql.cte.ExpressionSet // CommonTableExpressionSet
|
|
252
|
+
Sql.cte.DbService // CteDbService
|
|
253
|
+
Sql.where.Builder // WhereClauseBuilder
|
|
254
|
+
Sql.where.Callback // WhereClauseCallback
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`Sql.commonTableExpression` is an alias for `Sql.cte` (both the function and its namespace types).
|
|
258
|
+
|
|
259
|
+
## Important: Avoid Table Name Aliases
|
|
260
|
+
|
|
261
|
+
When writing SQL queries with these builders, avoid table name aliases when possible. Write full table names for clarity:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
// Preferred
|
|
265
|
+
where.and('users.status = ?', ['active'])
|
|
266
|
+
await builder.select('SELECT users.id, orders.total FROM users JOIN orders ON users.id = orders.userId')
|
|
267
|
+
|
|
268
|
+
// Avoid
|
|
269
|
+
where.and('u.status = ?', ['active'])
|
|
270
|
+
await builder.select('SELECT u.id, o.total FROM users u JOIN orders o ON u.id = o.userId')
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Testing
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
# Run tests
|
|
277
|
+
pnpm --filter @maestro-js/sql test
|
|
278
|
+
|
|
279
|
+
# Single test file
|
|
280
|
+
cd packages/sql && npx beartest ./tests/where-clause.test.ts
|
|
281
|
+
|
|
282
|
+
# Typecheck
|
|
283
|
+
pnpm --filter @maestro-js/sql typecheck
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Tests use `beartest-js` with `expect` for assertions. Test file location: `packages/sql/tests/where-clause.test.ts`.
|
|
287
|
+
|
|
288
|
+
## Cross-Package Integration
|
|
289
|
+
|
|
290
|
+
- **No runtime dependencies** on other maestro packages
|
|
291
|
+
- **Works with @maestro-js/db**: SQL builders produce `{ query, parameters }` pairs for Db to execute. The CTE builder wraps a Db service (or any object matching `CteDbService`) to auto-prepend WITH clauses.
|
|
292
|
+
- **@maestro-js/db is a devDependency** only, used for the `CteDbService` type interface
|
|
293
|
+
|
|
294
|
+
### Typical Usage with Db
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
import { Sql } from '@maestro-js/sql'
|
|
298
|
+
import { Db } from '@maestro-js/db'
|
|
299
|
+
|
|
300
|
+
// WHERE builder feeds into Db.select
|
|
301
|
+
const where = Sql.where()
|
|
302
|
+
where.and('active = ?', [true])
|
|
303
|
+
if (minPrice) where.and('price >= ?', [minPrice])
|
|
304
|
+
if (category) where.and('category = ?', [category])
|
|
305
|
+
|
|
306
|
+
const products = await Db.select(`SELECT * FROM products WHERE ${where.query}`, where.parameters)
|
|
307
|
+
|
|
308
|
+
// CTE builder wraps Db directly
|
|
309
|
+
const withMetrics = Sql.cte({
|
|
310
|
+
dailySales: {
|
|
311
|
+
subquery: '(SELECT DATE(created_at) as day, SUM(amount) as total FROM orders GROUP BY DATE(created_at))',
|
|
312
|
+
params: []
|
|
313
|
+
}
|
|
314
|
+
}, Db)
|
|
315
|
+
const trends = await withMetrics.select('SELECT * FROM dailySales WHERE total > ?', [1000])
|
|
316
|
+
```
|