@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.
@@ -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
+ ```