ts-procedures 5.2.0 → 5.3.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,572 @@
1
+ # ts-procedures Prescribed Patterns
2
+
3
+ Follow these patterns exactly when writing ts-procedures code.
4
+
5
+ > All examples use `import { Type } from 'typebox'` for schema definitions. This import is assumed unless shown explicitly.
6
+
7
+ ---
8
+
9
+ ## Basic Procedure Setup
10
+
11
+ ```typescript
12
+ import { Procedures } from 'ts-procedures'
13
+
14
+ // 1. Define context type
15
+ type AppContext = { userId: string; requestId: string }
16
+
17
+ // 2. Create factory (optionally with extended config)
18
+ const { Create, CreateStream, getProcedures } = Procedures<AppContext>()
19
+
20
+ // 3. Register procedures
21
+ const { GetUser, procedure, info } = Create(
22
+ 'GetUser',
23
+ { description: 'Fetch user by ID' },
24
+ async (ctx, params) => {
25
+ return { id: ctx.userId, name: 'John' }
26
+ }
27
+ )
28
+
29
+ // 4. Call procedure
30
+ const result = await GetUser({ userId: 'u1', requestId: 'r1' }, {})
31
+ // or: await procedure({ userId: 'u1', requestId: 'r1' }, {})
32
+ ```
33
+
34
+ ---
35
+
36
+ ## Schema Validation with TypeBox
37
+
38
+ ```typescript
39
+ import { Type } from 'typebox'
40
+
41
+ const { Create } = Procedures<AppContext>()
42
+
43
+ const { GetUser } = Create(
44
+ 'GetUser',
45
+ {
46
+ schema: {
47
+ params: Type.Object({
48
+ userId: Type.String(),
49
+ }),
50
+ returnType: Type.Object({
51
+ id: Type.String(),
52
+ name: Type.String(),
53
+ email: Type.String(),
54
+ }),
55
+ },
56
+ },
57
+ async (ctx, params) => {
58
+ // params is typed as { userId: string }
59
+ // params.userId is guaranteed to be a string (validated by AJV)
60
+ const user = await fetchUser(params.userId)
61
+ return user
62
+ }
63
+ )
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Error Handling in Handlers
69
+
70
+ Use `ctx.error()` for business logic errors. Never throw raw Error instances.
71
+
72
+ ```typescript
73
+ const { TransferFunds } = Create(
74
+ 'TransferFunds',
75
+ {
76
+ schema: {
77
+ params: Type.Object({
78
+ fromAccountId: Type.String(),
79
+ toAccountId: Type.String(),
80
+ amount: Type.Number(),
81
+ }),
82
+ },
83
+ },
84
+ async (ctx, params) => {
85
+ const account = await getAccount(params.fromAccountId)
86
+
87
+ if (!account) {
88
+ throw ctx.error('Account not found', { code: 'NOT_FOUND', accountId: params.fromAccountId })
89
+ }
90
+
91
+ if (account.balance < params.amount) {
92
+ throw ctx.error('Insufficient funds', {
93
+ code: 'INSUFFICIENT_FUNDS',
94
+ balance: account.balance,
95
+ requested: params.amount,
96
+ })
97
+ }
98
+
99
+ return await executeTransfer(params)
100
+ }
101
+ )
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Stream Procedures
107
+
108
+ ```typescript
109
+ const { StreamNotifications } = CreateStream(
110
+ 'StreamNotifications',
111
+ {
112
+ description: 'Real-time notification feed',
113
+ schema: {
114
+ params: Type.Object({
115
+ userId: Type.String(),
116
+ }),
117
+ yieldType: Type.Object({
118
+ id: Type.String(),
119
+ message: Type.String(),
120
+ timestamp: Type.Number(),
121
+ }),
122
+ },
123
+ },
124
+ async function* (ctx, params) {
125
+ // ctx.signal is always present in stream handlers
126
+ const subscription = subscribeToNotifications(params.userId)
127
+
128
+ try {
129
+ for await (const notification of subscription) {
130
+ if (ctx.signal.aborted) break
131
+ yield notification
132
+ }
133
+ } finally {
134
+ subscription.close()
135
+ }
136
+ }
137
+ )
138
+ ```
139
+
140
+ ---
141
+
142
+ ## AbortSignal in Stream Handlers
143
+
144
+ Always check `ctx.signal` for cancellation. Distinguish normal completion from client disconnect.
145
+
146
+ ```typescript
147
+ const { StreamData } = CreateStream(
148
+ 'StreamData',
149
+ {},
150
+ async function* (ctx) {
151
+ try {
152
+ while (!ctx.signal.aborted) {
153
+ const data = await fetchLatestData({ signal: ctx.signal })
154
+ yield data
155
+ await delay(1000)
156
+ }
157
+ } finally {
158
+ if (ctx.signal.reason === 'stream-completed') {
159
+ // Normal completion — consumer finished reading
160
+ console.log('Stream completed normally')
161
+ } else {
162
+ // Client disconnected or external abort
163
+ console.log('Stream aborted:', ctx.signal.reason)
164
+ }
165
+ }
166
+ }
167
+ )
168
+ ```
169
+
170
+ ---
171
+
172
+ ## AbortSignal in Standard Handlers
173
+
174
+ Pass the signal to downstream async calls for automatic cancellation.
175
+
176
+ ```typescript
177
+ const { LongOperation } = Create(
178
+ 'LongOperation',
179
+ {},
180
+ async (ctx, params) => {
181
+ // ctx.signal is available when HTTP implementation provides it
182
+ const data = await fetch('https://api.example.com/data', {
183
+ signal: ctx.signal,
184
+ })
185
+
186
+ const processed = await processData(data, { signal: ctx.signal })
187
+ return processed
188
+ }
189
+ )
190
+ ```
191
+
192
+ ---
193
+
194
+ ## Extended Config with RPCConfig
195
+
196
+ ```typescript
197
+ import { Procedures } from 'ts-procedures'
198
+ import { Type } from 'typebox'
199
+ import type { RPCConfig } from 'ts-procedures/http'
200
+
201
+ type AppContext = { userId: string }
202
+
203
+ const { Create } = Procedures<AppContext, RPCConfig>()
204
+
205
+ // Every procedure now MUST include scope and version
206
+ const { GetUser } = Create(
207
+ 'GetUser',
208
+ {
209
+ scope: 'users', // URL path segment
210
+ version: 1, // API version
211
+ description: 'Fetch user by ID',
212
+ schema: {
213
+ params: Type.Object({ userId: Type.String() }),
214
+ },
215
+ },
216
+ async (ctx, params) => {
217
+ return await fetchUser(params.userId)
218
+ }
219
+ )
220
+ // Route: POST /users/get-user/1
221
+ ```
222
+
223
+ ---
224
+
225
+ ## Custom Extended Config
226
+
227
+ ```typescript
228
+ interface AppConfig extends RPCConfig {
229
+ permissions?: string[]
230
+ deprecated?: boolean
231
+ }
232
+
233
+ const { Create, getProcedures } = Procedures<AppContext, AppConfig>()
234
+
235
+ const { AdminAction } = Create(
236
+ 'AdminAction',
237
+ {
238
+ scope: 'admin',
239
+ version: 1,
240
+ permissions: ['admin:write'],
241
+ description: 'Perform admin action',
242
+ },
243
+ async (ctx, params) => { /* ... */ }
244
+ )
245
+
246
+ // Use getProcedures() for introspection
247
+ for (const proc of getProcedures()) {
248
+ console.log(proc.name, proc.config.permissions)
249
+ }
250
+ ```
251
+
252
+ ---
253
+
254
+ ## Express RPC Integration
255
+
256
+ ```typescript
257
+ import { Procedures } from 'ts-procedures'
258
+ import { ExpressRPCAppBuilder } from 'ts-procedures/express-rpc'
259
+ import { Type } from 'typebox'
260
+ import type { RPCConfig } from 'ts-procedures/http'
261
+
262
+ // 1. Define context and procedures
263
+ type AppContext = { userId: string; requestId: string }
264
+ const { Create } = Procedures<AppContext, RPCConfig>()
265
+
266
+ const { GetUser } = Create(
267
+ 'GetUser',
268
+ { scope: 'users', version: 1, schema: { params: Type.Object({ id: Type.String() }) } },
269
+ async (ctx, params) => fetchUser(params.id)
270
+ )
271
+
272
+ const { UpdateUser } = Create(
273
+ 'UpdateUser',
274
+ { scope: 'users', version: 1 },
275
+ async (ctx, params) => updateUser(params)
276
+ )
277
+
278
+ // 2. Build Express app
279
+ const app = new ExpressRPCAppBuilder({
280
+ pathPrefix: '/api',
281
+ onError: (procedure, req, res, error) => {
282
+ if (error instanceof ProcedureValidationError) {
283
+ res.status(400).json({ error: error.message, details: error.errors })
284
+ } else {
285
+ res.status(500).json({ error: error.message })
286
+ }
287
+ },
288
+ })
289
+ .register(
290
+ { getProcedures: () => [GetUser.info, UpdateUser.info] }, // Or use the factory directly
291
+ async (req) => ({
292
+ userId: await getUserIdFromToken(req.headers.authorization),
293
+ requestId: req.headers['x-request-id'] || crypto.randomUUID(),
294
+ })
295
+ )
296
+ .build()
297
+
298
+ // Routes created:
299
+ // POST /api/users/get-user/1
300
+ // POST /api/users/update-user/1
301
+ ```
302
+
303
+ ---
304
+
305
+ ## Hono RPC Integration
306
+
307
+ ```typescript
308
+ import { Procedures } from 'ts-procedures'
309
+ import { HonoRPCAppBuilder } from 'ts-procedures/hono-rpc'
310
+ import type { RPCConfig } from 'ts-procedures/http'
311
+
312
+ type AppContext = { userId: string }
313
+ const RPC = Procedures<AppContext, RPCConfig>()
314
+
315
+ RPC.Create('GetUser', { scope: 'users', version: 1 }, async (ctx, params) => {
316
+ return fetchUser(params.id)
317
+ })
318
+
319
+ const app = new HonoRPCAppBuilder({ pathPrefix: '/api' })
320
+ .register(RPC, (c) => ({
321
+ userId: c.req.header('x-user-id') || 'anonymous',
322
+ }))
323
+ .build()
324
+ ```
325
+
326
+ ---
327
+
328
+ ## Hono Streaming Integration
329
+
330
+ ```typescript
331
+ import { Procedures } from 'ts-procedures'
332
+ import { HonoStreamAppBuilder, sse } from 'ts-procedures/hono-stream'
333
+ import { Type } from 'typebox'
334
+ import type { RPCConfig } from 'ts-procedures/http'
335
+
336
+ type StreamContext = { userId: string }
337
+ const StreamRPC = Procedures<StreamContext, RPCConfig>()
338
+
339
+ const { StreamEvents } = StreamRPC.CreateStream(
340
+ 'StreamEvents',
341
+ {
342
+ scope: 'events',
343
+ version: 1,
344
+ schema: {
345
+ params: Type.Object({ channel: Type.String() }),
346
+ yieldType: Type.Object({ type: Type.String(), payload: Type.Any() }),
347
+ },
348
+ },
349
+ async function* (ctx, params) {
350
+ const sub = subscribe(params.channel, { signal: ctx.signal })
351
+ for await (const event of sub) {
352
+ // Attach SSE metadata
353
+ yield sse(event, { event: event.type, id: event.id })
354
+ }
355
+ }
356
+ )
357
+
358
+ const app = new HonoStreamAppBuilder({
359
+ pathPrefix: '/api',
360
+ defaultStreamMode: 'sse',
361
+ onMidStreamError: (procedure, c, error) => ({
362
+ data: { error: error.message },
363
+ closeStream: true,
364
+ }),
365
+ })
366
+ .register(StreamRPC, (c) => ({
367
+ userId: c.req.header('x-user-id') || 'anonymous',
368
+ }))
369
+ .build()
370
+
371
+ // Routes: GET|POST /api/events/stream-events/1
372
+ ```
373
+
374
+ ---
375
+
376
+ ## Text Streaming Mode
377
+
378
+ ```typescript
379
+ const app = new HonoStreamAppBuilder({
380
+ defaultStreamMode: 'text', // Newline-delimited JSON
381
+ })
382
+ .register(StreamRPC, contextFactory)
383
+ .build()
384
+
385
+ // Each yield becomes: {"type":"event","payload":{...}}\n
386
+ ```
387
+
388
+ ---
389
+
390
+ ## onCreate Callback for Framework Integration
391
+
392
+ ```typescript
393
+ const registeredProcedures: TProcedureRegistration[] = []
394
+
395
+ const { Create } = Procedures<AppContext>({
396
+ onCreate: (procedure) => {
397
+ registeredProcedures.push(procedure)
398
+ console.log(`Registered: ${procedure.name}`)
399
+ },
400
+ })
401
+
402
+ // Use for:
403
+ // - Route registration in custom frameworks
404
+ // - OpenAPI spec generation
405
+ // - Logging/monitoring setup
406
+ // - Permission registration
407
+ ```
408
+
409
+ ---
410
+
411
+ ## getProcedures() for Introspection
412
+
413
+ ```typescript
414
+ const { Create, getProcedures } = Procedures<AppContext, RPCConfig>()
415
+
416
+ Create('GetUser', { scope: 'users', version: 1 }, async (ctx) => ({}))
417
+ Create('ListUsers', { scope: 'users', version: 1 }, async (ctx) => [])
418
+
419
+ // Generate documentation
420
+ const docs = getProcedures().map(proc => ({
421
+ name: proc.name,
422
+ path: `/${proc.config.scope}/${kebabCase(proc.name)}/${proc.config.version}`,
423
+ params: proc.config.schema?.params,
424
+ returnType: proc.config.schema?.returnType,
425
+ }))
426
+ ```
427
+
428
+ ---
429
+
430
+ ## Multiple Factories for Access Control
431
+
432
+ ```typescript
433
+ type PublicContext = { requestId: string }
434
+ type AuthContext = { userId: string; requestId: string }
435
+
436
+ const PublicRPC = Procedures<PublicContext, RPCConfig>()
437
+ const AuthRPC = Procedures<AuthContext, RPCConfig>()
438
+
439
+ PublicRPC.Create('HealthCheck', { scope: 'health', version: 1 }, async () => ({ status: 'ok' }))
440
+ AuthRPC.Create('GetProfile', { scope: 'users', version: 1 }, async (ctx) => fetchProfile(ctx.userId))
441
+
442
+ // Register with different context resolvers
443
+ const app = new ExpressRPCAppBuilder({ pathPrefix: '/api' })
444
+ .register(PublicRPC, (req) => ({ requestId: req.headers['x-request-id'] }))
445
+ .register(AuthRPC, async (req) => ({
446
+ userId: await authenticate(req),
447
+ requestId: req.headers['x-request-id'],
448
+ }))
449
+ .build()
450
+ ```
451
+
452
+ ---
453
+
454
+ ## Documentation Generation with extendProcedureDoc
455
+
456
+ ```typescript
457
+ const app = new ExpressRPCAppBuilder({ pathPrefix: '/api' })
458
+ .register(RPC, contextFactory, ({ base, procedure }) => ({
459
+ summary: procedure.config.description,
460
+ tags: [base.scope],
461
+ security: procedure.config.permissions
462
+ ? [{ bearerAuth: [] }]
463
+ : [],
464
+ }))
465
+ .build()
466
+
467
+ // Access generated docs
468
+ const openApiPaths = app.docs.map(doc => ({
469
+ [doc.path]: {
470
+ [doc.method]: {
471
+ summary: doc.summary,
472
+ tags: doc.tags,
473
+ requestBody: doc.jsonSchema.body
474
+ ? { content: { 'application/json': { schema: doc.jsonSchema.body } } }
475
+ : undefined,
476
+ responses: {
477
+ 200: doc.jsonSchema.response
478
+ ? { content: { 'application/json': { schema: doc.jsonSchema.response } } }
479
+ : { description: 'Success' },
480
+ },
481
+ },
482
+ },
483
+ }))
484
+ ```
485
+
486
+ ---
487
+
488
+ ## Testing Procedures
489
+
490
+ ```typescript
491
+ import { describe, test, expect } from 'vitest'
492
+
493
+ describe('GetUser', () => {
494
+ test('returns user for valid params', async () => {
495
+ const result = await GetUser(
496
+ { userId: 'caller-1', requestId: 'test' },
497
+ { id: 'user-123' }
498
+ )
499
+ expect(result).toEqual({ id: 'user-123', name: 'John' })
500
+ })
501
+
502
+ test('throws ProcedureValidationError for invalid params', async () => {
503
+ await expect(
504
+ GetUser({ userId: 'caller-1', requestId: 'test' }, {})
505
+ ).rejects.toThrow(ProcedureValidationError)
506
+ })
507
+
508
+ test('throws ProcedureError for business logic errors', async () => {
509
+ await expect(
510
+ GetUser({ userId: 'caller-1', requestId: 'test' }, { id: 'not-found' })
511
+ ).rejects.toThrow(ProcedureError)
512
+ })
513
+ })
514
+ ```
515
+
516
+ ---
517
+
518
+ ## Testing Stream Procedures
519
+
520
+ ```typescript
521
+ describe('StreamNotifications', () => {
522
+ test('yields notifications', async () => {
523
+ const values = []
524
+ for await (const val of StreamNotifications({ userId: 'u1' }, { userId: 'u1' })) {
525
+ values.push(val)
526
+ if (values.length >= 3) break
527
+ }
528
+ expect(values).toHaveLength(3)
529
+ })
530
+ })
531
+ ```
532
+
533
+ ---
534
+
535
+ ## Testing HTTP Builders
536
+
537
+ ```typescript
538
+ import supertest from 'supertest'
539
+
540
+ describe('Express RPC', () => {
541
+ const app = new ExpressRPCAppBuilder({ pathPrefix: '/api' })
542
+ .register(RPC, { userId: 'test-user', requestId: 'test' })
543
+ .build()
544
+
545
+ test('POST /api/users/get-user/1', async () => {
546
+ const res = await supertest(app)
547
+ .post('/api/users/get-user/1')
548
+ .send({ id: 'user-123' })
549
+ .expect(200)
550
+
551
+ expect(res.body).toEqual({ id: 'user-123', name: 'John' })
552
+ })
553
+ })
554
+ ```
555
+
556
+ ---
557
+
558
+ ## Lifecycle Hook Execution Order
559
+
560
+ ### Standard RPC (Express/Hono)
561
+ ```
562
+ onRequestStart → factoryContext() → handler() → onSuccess → onRequestEnd
563
+ → onError → onRequestEnd
564
+ ```
565
+
566
+ ### Streaming (HonoStreamAppBuilder)
567
+ ```
568
+ onRequestStart → factoryContext() → params validation
569
+ → onPreStreamError (if invalid) → onRequestEnd
570
+ → onStreamStart → handler yields → onStreamEnd → onRequestEnd
571
+ → onMidStreamError (if throw) → onStreamEnd → onRequestEnd
572
+ ```
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: review
3
+ description: "Review code for ts-procedures pattern adherence. Usage: /ts-procedures:review <path>"
4
+ invocable_by:
5
+ - user
6
+ - model
7
+ user_instructions: |
8
+ Usage: /ts-procedures:review <path>
9
+
10
+ Reviews files at the given path for ts-procedures pattern adherence.
11
+ Accepts a file path or directory.
12
+
13
+ Examples:
14
+ /ts-procedures:review src/procedures/
15
+ /ts-procedures:review src/procedures/GetUser.procedure.ts
16
+ ---
17
+
18
+ # Review ts-procedures Code
19
+
20
+ Parse `$ARGUMENTS` as a file or directory path. If a directory, review all `.ts` and `.tsx` files within it.
21
+
22
+ ## Instructions
23
+
24
+ 1. Read the target file(s).
25
+ 2. Identify ts-procedures imports (`ts-procedures`, `ts-procedures/express-rpc`, `ts-procedures/hono-rpc`, `ts-procedures/hono-stream`, `ts-procedures/http`) to determine file types.
26
+ 3. Check each file against the categorized checklist in `checklist.md`.
27
+ 4. Output findings grouped by severity.
28
+
29
+ ## Output Format
30
+
31
+ For each finding:
32
+
33
+ ```
34
+ [SEVERITY] file:line — Violation
35
+ Problem: What's wrong
36
+ Fix: Concrete before/after code
37
+ ```
38
+
39
+ Severity levels:
40
+ - **CRITICAL** — Will cause bugs, silent failures, resource leaks, or runtime errors. Must fix.
41
+ - **WARNING** — Anti-pattern that hurts maintainability or correctness. Should fix.
42
+ - **SUGGESTION** — Improvement for readability, type safety, or DX. Nice to have.
43
+
44
+ ## Summary
45
+
46
+ After individual findings, provide:
47
+ - Total findings by severity
48
+ - Overall assessment (healthy / needs attention / significant issues)
49
+ - Top 3 priorities to address
50
+
51
+ ## Reference
52
+
53
+ See `checklist.md` for the complete categorized checklist by file type.