ts-procedures 3.1.0 → 3.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.
Files changed (54) hide show
  1. package/README.md +222 -2
  2. package/build/errors.d.ts +19 -3
  3. package/build/errors.js +54 -5
  4. package/build/errors.js.map +1 -1
  5. package/build/errors.test.js +82 -0
  6. package/build/errors.test.js.map +1 -1
  7. package/build/exports.d.ts +1 -0
  8. package/build/exports.js +1 -0
  9. package/build/exports.js.map +1 -1
  10. package/build/implementations/http/hono-stream/index.d.ts +92 -0
  11. package/build/implementations/http/hono-stream/index.js +229 -0
  12. package/build/implementations/http/hono-stream/index.js.map +1 -0
  13. package/build/implementations/http/hono-stream/index.test.d.ts +1 -0
  14. package/build/implementations/http/hono-stream/index.test.js +681 -0
  15. package/build/implementations/http/hono-stream/index.test.js.map +1 -0
  16. package/build/implementations/http/hono-stream/types.d.ts +24 -0
  17. package/build/implementations/http/hono-stream/types.js +2 -0
  18. package/build/implementations/http/hono-stream/types.js.map +1 -0
  19. package/build/implementations/types.d.ts +15 -1
  20. package/build/index.d.ts +62 -3
  21. package/build/index.js +111 -6
  22. package/build/index.js.map +1 -1
  23. package/build/index.test.js +385 -2
  24. package/build/index.test.js.map +1 -1
  25. package/build/schema/compute-schema.d.ts +9 -2
  26. package/build/schema/compute-schema.js +9 -3
  27. package/build/schema/compute-schema.js.map +1 -1
  28. package/build/schema/parser.d.ts +6 -0
  29. package/build/schema/parser.js +42 -0
  30. package/build/schema/parser.js.map +1 -1
  31. package/build/schema/types.d.ts +1 -0
  32. package/build/stack-utils.d.ts +25 -0
  33. package/build/stack-utils.js +95 -0
  34. package/build/stack-utils.js.map +1 -0
  35. package/build/stack-utils.test.d.ts +1 -0
  36. package/build/stack-utils.test.js +80 -0
  37. package/build/stack-utils.test.js.map +1 -0
  38. package/package.json +1 -1
  39. package/src/errors.test.ts +110 -0
  40. package/src/errors.ts +65 -3
  41. package/src/exports.ts +1 -0
  42. package/src/implementations/http/README.md +87 -55
  43. package/src/implementations/http/hono-stream/README.md +261 -0
  44. package/src/implementations/http/hono-stream/index.test.ts +1009 -0
  45. package/src/implementations/http/hono-stream/index.ts +327 -0
  46. package/src/implementations/http/hono-stream/types.ts +29 -0
  47. package/src/implementations/types.ts +17 -1
  48. package/src/index.test.ts +525 -41
  49. package/src/index.ts +210 -8
  50. package/src/schema/compute-schema.ts +15 -3
  51. package/src/schema/parser.ts +55 -4
  52. package/src/schema/types.ts +4 -0
  53. package/src/stack-utils.test.ts +94 -0
  54. package/src/stack-utils.ts +129 -0
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { ProcedureError, ProcedureValidationError } from './errors.js'
1
+ import { ProcedureError, ProcedureValidationError, ProcedureYieldValidationError } from './errors.js'
2
2
  import { computeSchema } from './schema/compute-schema.js'
3
3
  import { Prettify, TJSONSchema, TSchemaLib } from './schema/types.js'
4
+ import { captureDefinitionInfo } from './stack-utils.js'
4
5
 
5
6
  export type TNoContextProvided = unknown
6
7
 
@@ -8,8 +9,13 @@ export type TLocalContext = {
8
9
  error: (message: string, meta?: object) => ProcedureError
9
10
  }
10
11
 
12
+ export type TStreamContext = TLocalContext & {
13
+ signal: AbortSignal
14
+ }
15
+
11
16
  export type TProcedureRegistration<TContext = unknown, TExtendedConfig = unknown> = {
12
17
  name: string
18
+ isStream?: false
13
19
  config: {
14
20
  description?: string
15
21
  schema?: {
@@ -24,6 +30,25 @@ export type TProcedureRegistration<TContext = unknown, TExtendedConfig = unknown
24
30
  handler: (ctx: TContext, params?: any) => Promise<any>
25
31
  }
26
32
 
33
+ export type TStreamProcedureRegistration<TContext = unknown, TExtendedConfig = unknown> = {
34
+ name: string
35
+ isStream: true
36
+ config: {
37
+ description?: string
38
+ schema?: {
39
+ params?: TJSONSchema
40
+ yieldType?: TJSONSchema
41
+ returnType?: TJSONSchema
42
+ }
43
+ validation?: {
44
+ params?: (params: any) => { errors?: any[] }
45
+ yield?: (value: any) => { errors?: any[] }
46
+ }
47
+ } & TExtendedConfig
48
+
49
+ handler: (ctx: TContext, params?: any) => AsyncGenerator<any, any, unknown>
50
+ }
51
+
27
52
  export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unknown>(
28
53
  /**
29
54
  * Optionally provided builder to register Procedures
@@ -32,16 +57,19 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
32
57
  onCreate?: (
33
58
  procedure: Prettify<{
34
59
  name: string
35
- handler: (ctx: Prettify<TContext>, params?: any) => Promise<any>
60
+ isStream?: boolean
61
+ handler: ((ctx: Prettify<TContext>, params?: any) => Promise<any>) | ((ctx: Prettify<TContext>, params?: any) => AsyncGenerator<any, any, unknown>)
36
62
  config: Prettify<
37
63
  {
38
64
  description?: string
39
65
  schema?: {
40
66
  params?: TJSONSchema
67
+ yieldType?: TJSONSchema
41
68
  returnType?: TJSONSchema
42
69
  }
43
70
  validation?: {
44
71
  params?: (params: any) => { errors?: any[] }
72
+ yield?: (value: any) => { errors?: any[] }
45
73
  }
46
74
  } & TExtendedConfig
47
75
  >
@@ -51,7 +79,7 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
51
79
  ) {
52
80
  const procedures: Map<
53
81
  string,
54
- TProcedureRegistration<TContext, TExtendedConfig>
82
+ TProcedureRegistration<TContext, TExtendedConfig> | TStreamProcedureRegistration<TContext, TExtendedConfig>
55
83
  > = new Map()
56
84
 
57
85
  function Create<TName extends string, TParams, TReturnType>(
@@ -68,16 +96,19 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
68
96
  params: TSchemaLib<TParams>
69
97
  ) => Promise<TSchemaLib<TReturnType>>
70
98
  ) {
99
+ // Capture definition location as first action
100
+ const definitionInfo = captureDefinitionInfo()
101
+
71
102
  // BEFORE computeSchema - fail fast on duplicate
72
103
  if (procedures.has(name)) {
73
104
  throw new Error(`Procedure with name ${name} is already registered`)
74
105
  }
75
106
 
76
- const { jsonSchema, validations } = computeSchema(name, config.schema)
107
+ const { jsonSchema, validations } = computeSchema(name, config.schema, definitionInfo)
77
108
 
78
109
  // Create error factory once at registration time (outside handler)
79
110
  const errorFactory = (message: string, meta?: object) => {
80
- return new ProcedureError(name, message, meta)
111
+ return new ProcedureError(name, message, meta, definitionInfo)
81
112
  }
82
113
 
83
114
  const registeredProcedure = {
@@ -101,7 +132,8 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
101
132
  throw new ProcedureValidationError(
102
133
  name,
103
134
  `Validation error for ${name} - ${errors.map((e) => e.message).join(', ')}`,
104
- errors
135
+ errors,
136
+ definitionInfo
105
137
  )
106
138
  }
107
139
  }
@@ -121,9 +153,20 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
121
153
  if (error instanceof ProcedureError) {
122
154
  throw error
123
155
  } else {
124
- const err = new ProcedureError(name, `Error in handler for ${name} - ${error?.message}`)
156
+ const err = new ProcedureError(
157
+ name,
158
+ `Error in handler for ${name} - ${error?.message}`,
159
+ undefined,
160
+ definitionInfo
161
+ )
125
162
  err.cause = error // Preserve original error
126
- err.stack = error.stack
163
+ // Preserve original stack but append definition info
164
+ if (error.stack && definitionInfo.definedAt) {
165
+ const { file, line, column } = definitionInfo.definedAt
166
+ err.stack = error.stack + `\n--- Procedure "${name}" defined at ---\n at ${file}:${line}:${column}`
167
+ } else if (error.stack) {
168
+ err.stack = error.stack
169
+ }
127
170
  throw err
128
171
  }
129
172
  }
@@ -164,6 +207,164 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
164
207
  }
165
208
  }
166
209
 
210
+ function CreateStream<TName extends string, TParams, TYieldType, TReturnType = void>(
211
+ name: TName,
212
+ config: {
213
+ description?: string
214
+ schema?: {
215
+ params?: TParams
216
+ yieldType?: TYieldType
217
+ returnType?: TReturnType
218
+ }
219
+ validateYields?: boolean
220
+ } & TExtendedConfig,
221
+ handler: (
222
+ ctx: Prettify<TContext & TStreamContext>,
223
+ params: TSchemaLib<TParams>
224
+ ) => AsyncGenerator<TSchemaLib<TYieldType>, TSchemaLib<TReturnType> | void, unknown>
225
+ ) {
226
+ // Capture definition location as first action
227
+ const definitionInfo = captureDefinitionInfo()
228
+
229
+ // BEFORE computeSchema - fail fast on duplicate
230
+ if (procedures.has(name)) {
231
+ throw new Error(`Procedure with name ${name} is already registered`)
232
+ }
233
+
234
+ const { jsonSchema, validations } = computeSchema(name, config.schema, definitionInfo)
235
+
236
+ // Create error factory once at registration time (outside handler)
237
+ const errorFactory = (message: string, meta?: object) => {
238
+ return new ProcedureError(name, message, meta, definitionInfo)
239
+ }
240
+
241
+ const validateYields = config.validateYields ?? false
242
+
243
+ const registeredProcedure: TStreamProcedureRegistration<TContext, TExtendedConfig> = {
244
+ name,
245
+ isStream: true,
246
+ config: {
247
+ ...config,
248
+ description: config.description,
249
+ schema: jsonSchema,
250
+ validation: {
251
+ params: validations.params,
252
+ yield: validations.yield,
253
+ },
254
+ },
255
+
256
+ handler: async function* wrappedHandler(ctx: Prettify<TContext>, params: TSchemaLib<TParams>) {
257
+ // Create abort controller for this stream
258
+ const abortController = new AbortController()
259
+
260
+ // Validate params first
261
+ if (validations?.params) {
262
+ const { errors } = validations.params(params)
263
+
264
+ if (errors) {
265
+ throw new ProcedureValidationError(
266
+ name,
267
+ `Validation error for ${name} - ${errors.map((e) => e.message).join(', ')}`,
268
+ errors,
269
+ definitionInfo
270
+ )
271
+ }
272
+ }
273
+
274
+ const streamCtx: TStreamContext = {
275
+ error: errorFactory,
276
+ signal: abortController.signal,
277
+ }
278
+
279
+ const userGenerator = handler(
280
+ {
281
+ ...ctx,
282
+ ...streamCtx,
283
+ } as Prettify<TContext & TStreamContext>,
284
+ params
285
+ )
286
+
287
+ try {
288
+ for await (const value of userGenerator) {
289
+ // Only validate if explicitly enabled via validateYields: true
290
+ if (validateYields && validations.yield) {
291
+ const { errors } = validations.yield(value)
292
+ if (errors) {
293
+ throw new ProcedureYieldValidationError(
294
+ name,
295
+ `Yield validation error for ${name} - ${errors.map((e) => e.message).join(', ')}`,
296
+ errors,
297
+ definitionInfo
298
+ )
299
+ }
300
+ }
301
+
302
+ yield value
303
+ }
304
+ } catch (error: any) {
305
+ if (error instanceof ProcedureError) {
306
+ throw error
307
+ } else {
308
+ const err = new ProcedureError(
309
+ name,
310
+ `Error in streaming handler for ${name} - ${error?.message}`,
311
+ undefined,
312
+ definitionInfo
313
+ )
314
+ err.cause = error
315
+ if (error.stack && definitionInfo.definedAt) {
316
+ const { file, line, column } = definitionInfo.definedAt
317
+ err.stack = error.stack + `\n--- Procedure "${name}" defined at ---\n at ${file}:${line}:${column}`
318
+ } else if (error.stack) {
319
+ err.stack = error.stack
320
+ }
321
+ throw err
322
+ }
323
+ } finally {
324
+ // Signal abort on early termination
325
+ abortController.abort()
326
+ }
327
+ } as (ctx: TContext, params?: any) => AsyncGenerator<any, any, unknown>,
328
+ }
329
+
330
+ procedures.set(name, registeredProcedure)
331
+
332
+ if (builder?.onCreate) {
333
+ builder.onCreate(registeredProcedure)
334
+ }
335
+
336
+ const info = {
337
+ name,
338
+ isStream: true as const,
339
+ ...registeredProcedure.config,
340
+ }
341
+
342
+ // return so can be called directly (ie: int/unit tests)
343
+ return {
344
+ [name]: registeredProcedure.handler,
345
+ procedure: registeredProcedure.handler,
346
+ info,
347
+ } as {
348
+ [K in TName]: (ctx: Prettify<TContext>, params: TSchemaLib<TParams>) => AsyncGenerator<TSchemaLib<TYieldType>, TSchemaLib<TReturnType> | void, unknown>
349
+ } & {
350
+ procedure: (ctx: Prettify<TContext>, params: TSchemaLib<TParams>) => AsyncGenerator<TSchemaLib<TYieldType>, TSchemaLib<TReturnType> | void, unknown>
351
+ info: {
352
+ name: TName
353
+ isStream: true
354
+ description?: string
355
+ schema: {
356
+ params?: TParams
357
+ yieldType?: TYieldType
358
+ returnType?: TReturnType
359
+ }
360
+ validation?: {
361
+ params?: (params: any) => { errors?: any[] }
362
+ yield?: (value: any) => { errors?: any[] }
363
+ }
364
+ } & TExtendedConfig
365
+ }
366
+ }
367
+
167
368
  return {
168
369
  /**
169
370
  * Get all registered procedures
@@ -194,5 +395,6 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
194
395
  },
195
396
 
196
397
  Create,
398
+ CreateStream,
197
399
  }
198
400
  }
@@ -1,6 +1,7 @@
1
1
  import { schemaParser, TSchemaValidationError } from './parser.js'
2
2
  import { ProcedureRegistrationError } from '../errors.js'
3
3
  import { TJSONSchema } from './types.js'
4
+ import { DefinitionInfo } from '../stack-utils.js'
4
5
 
5
6
  /**
6
7
  * This function is used to compute the JSON schema and validation functions
@@ -8,34 +9,42 @@ import { TJSONSchema } from './types.js'
8
9
  *
9
10
  * @param name The name of the procedure
10
11
  * @param schema Procedure schema
12
+ * @param definitionInfo Optional definition info for error reporting
11
13
  */
12
- export function computeSchema<TParamsSchemaType, TReturnTypeSchemaType>(
14
+ export function computeSchema<TParamsSchemaType, TReturnTypeSchemaType, TYieldTypeSchemaType = unknown>(
13
15
  name: string,
14
16
  schema?: {
15
17
  params?: TParamsSchemaType
16
18
  returnType?: TReturnTypeSchemaType
19
+ yieldType?: TYieldTypeSchemaType
17
20
  },
21
+ // Used for error stack trace details
22
+ definitionInfo?: DefinitionInfo
18
23
  ): {
19
24
  jsonSchema: {
20
25
  params?: TJSONSchema
21
26
  returnType?: TJSONSchema
27
+ yieldType?: TJSONSchema
22
28
  }
23
29
  validations: {
24
30
  params?: (params?: any) => { errors?: TSchemaValidationError[] }
31
+ yield?: (value?: any) => { errors?: TSchemaValidationError[] }
25
32
  }
26
33
  } {
27
- const jsonSchema: { params?: TJSONSchema; returnType?: TJSONSchema } = {
34
+ const jsonSchema: { params?: TJSONSchema; returnType?: TJSONSchema; yieldType?: TJSONSchema } = {
28
35
  params: undefined,
29
36
  returnType: undefined,
37
+ yieldType: undefined,
30
38
  }
31
39
 
32
40
  const validations: {
33
41
  params?: (params?: any) => { errors?: TSchemaValidationError[] }
42
+ yield?: (value?: any) => { errors?: TSchemaValidationError[] }
34
43
  } = {}
35
44
 
36
45
  if (schema) {
37
46
  const {
38
- jsonSchema: { params, returnType },
47
+ jsonSchema: { params, returnType, yieldType },
39
48
  validation,
40
49
  } = schemaParser(schema, (errors) => {
41
50
  throw new ProcedureRegistrationError(
@@ -43,12 +52,15 @@ export function computeSchema<TParamsSchemaType, TReturnTypeSchemaType>(
43
52
  `Error parsing schema for ${name} - ${Object.entries(errors)
44
53
  .map(([key, error]) => `${key}: ${error}`)
45
54
  .join(', ')}`,
55
+ definitionInfo
46
56
  )
47
57
  })
48
58
 
49
59
  jsonSchema.params = params
50
60
  jsonSchema.returnType = returnType
61
+ jsonSchema.yieldType = yieldType
51
62
  validations.params = validation.params
63
+ validations.yield = validation.yield
52
64
  }
53
65
 
54
66
  return { jsonSchema, validations }
@@ -4,8 +4,11 @@ import { extractJsonSchema } from './extract-json-schema.js'
4
4
  import { TJSONSchema } from './types.js'
5
5
 
6
6
  export type TSchemaParsed = {
7
- jsonSchema: { params?: TJSONSchema; returnType?: TJSONSchema }
8
- validation: { params?: (params: any) => { errors?: TSchemaValidationError[] } }
7
+ jsonSchema: { params?: TJSONSchema; returnType?: TJSONSchema; yieldType?: TJSONSchema }
8
+ validation: {
9
+ params?: (params: any) => { errors?: TSchemaValidationError[] }
10
+ yield?: (value: any) => { errors?: TSchemaValidationError[] }
11
+ }
9
12
  }
10
13
 
11
14
  export type TSchemaValidationError = AJV.ErrorObject
@@ -21,8 +24,8 @@ const ajv = addFormats(
21
24
  )
22
25
 
23
26
  export function schemaParser(
24
- schema: { params?: unknown; returnType?: unknown },
25
- onParseError: (errors: { params?: string; returnType?: string }) => void
27
+ schema: { params?: unknown; returnType?: unknown; yieldType?: unknown },
28
+ onParseError: (errors: { params?: string; returnType?: string; yieldType?: string }) => void
26
29
  ): TSchemaParsed {
27
30
  const jsonSchema: TSchemaParsed['jsonSchema'] = {}
28
31
  const validation: TSchemaParsed['validation'] = {}
@@ -93,5 +96,53 @@ export function schemaParser(
93
96
  }
94
97
  }
95
98
 
99
+ if (schema.yieldType) {
100
+ try {
101
+ const extracted = extractJsonSchema(schema.yieldType as TJSONSchema)
102
+
103
+ if (extracted) {
104
+ jsonSchema.yieldType = extracted
105
+ }
106
+ } catch (e: any) {
107
+ onParseError({
108
+ yieldType: `Error extracting json schema schema.yieldType - ${e.message}`,
109
+ })
110
+ }
111
+
112
+ if (!jsonSchema.yieldType) {
113
+ onParseError({
114
+ yieldType: `Error extracting json schema schema.yieldType - schema.yieldType might be empty or it is not a valid suretype or typebox type`,
115
+ })
116
+ } else {
117
+ let yieldValidator: AJV.ValidateFunction | undefined
118
+
119
+ try {
120
+ yieldValidator = ajv.compile(jsonSchema.yieldType as TJSONSchema)
121
+ } catch (e: any) {
122
+ onParseError({
123
+ yieldType: `Error compiling schema.yieldType for validator - ${e.message}`,
124
+ })
125
+ }
126
+
127
+ validation.yield = (value: any) => {
128
+ if (!yieldValidator) {
129
+ return { errors: [{ message: 'Validator not initialized', keyword: 'internal' } as TSchemaValidationError] }
130
+ }
131
+
132
+ const valid = yieldValidator(value)
133
+
134
+ if (!valid) {
135
+ const errors = yieldValidator.errors
136
+
137
+ return {
138
+ errors: errors?.length ? errors : undefined,
139
+ }
140
+ }
141
+
142
+ return {}
143
+ }
144
+ }
145
+ }
146
+
96
147
  return { jsonSchema, validation }
97
148
  }
@@ -9,6 +9,10 @@ export type TSchemaLib<SchemaLibType> =
9
9
  ? Static<SchemaLibType>
10
10
  : unknown
11
11
 
12
+ // AsyncGenerator type extraction for streaming procedures
13
+ export type TSchemaLibGenerator<TYield, TReturn = void> =
14
+ AsyncGenerator<TSchemaLib<TYield>, TSchemaLib<TReturn>, unknown>
15
+
12
16
  export type TJSONSchema = Record<string, any>
13
17
 
14
18
  export type Prettify<TObject> = {
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { captureDefinitionInfo, formatDefinitionInfo, DefinitionInfo } from './stack-utils.js'
3
+
4
+ describe('Stack Utils', () => {
5
+ describe('captureDefinitionInfo', () => {
6
+ test('returns definition info with definedAt', () => {
7
+ const info = captureDefinitionInfo()
8
+
9
+ // Should capture the call site in this test file
10
+ expect(info).toBeDefined()
11
+ expect(info.definitionStack).toBeDefined()
12
+ expect(typeof info.definitionStack).toBe('string')
13
+ })
14
+
15
+ test('definedAt contains file, line, column when available', () => {
16
+ const info = captureDefinitionInfo()
17
+
18
+ // The definedAt should be present since we're calling from user code (test file)
19
+ if (info.definedAt) {
20
+ expect(info.definedAt.file).toBeDefined()
21
+ expect(typeof info.definedAt.file).toBe('string')
22
+ expect(info.definedAt.line).toBeDefined()
23
+ expect(typeof info.definedAt.line).toBe('number')
24
+ expect(info.definedAt.line).toBeGreaterThan(0)
25
+ expect(info.definedAt.column).toBeDefined()
26
+ expect(typeof info.definedAt.column).toBe('number')
27
+ expect(info.definedAt.column).toBeGreaterThan(0)
28
+ expect(info.definedAt.raw).toBeDefined()
29
+ expect(typeof info.definedAt.raw).toBe('string')
30
+ }
31
+ })
32
+
33
+ test('definitionStack contains Error stack trace', () => {
34
+ const info = captureDefinitionInfo()
35
+
36
+ expect(info.definitionStack).toContain('Error')
37
+ expect(info.definitionStack).toContain('at ')
38
+ })
39
+ })
40
+
41
+ describe('formatDefinitionInfo', () => {
42
+ test('returns undefined when definedAt is not present', () => {
43
+ const info: DefinitionInfo = {}
44
+ const result = formatDefinitionInfo(info, 'TestProcedure')
45
+
46
+ expect(result).toBeUndefined()
47
+ })
48
+
49
+ test('returns formatted string when definedAt is present', () => {
50
+ const info: DefinitionInfo = {
51
+ definedAt: {
52
+ file: '/app/procedures/test.ts',
53
+ line: 42,
54
+ column: 5,
55
+ raw: 'at Object.<anonymous> (/app/procedures/test.ts:42:5)',
56
+ },
57
+ }
58
+ const result = formatDefinitionInfo(info, 'TestProcedure')
59
+
60
+ expect(result).toBeDefined()
61
+ expect(result).toContain('--- Procedure "TestProcedure" defined at ---')
62
+ expect(result).toContain('/app/procedures/test.ts:42:5')
63
+ })
64
+
65
+ test('includes procedure name in formatted output', () => {
66
+ const info: DefinitionInfo = {
67
+ definedAt: {
68
+ file: '/path/to/file.ts',
69
+ line: 10,
70
+ column: 3,
71
+ raw: 'at /path/to/file.ts:10:3',
72
+ },
73
+ }
74
+ const result = formatDefinitionInfo(info, 'MyCustomProcedure')
75
+
76
+ expect(result).toContain('"MyCustomProcedure"')
77
+ })
78
+ })
79
+
80
+ describe('integration with procedure creation', () => {
81
+ test('captures location from calling code', () => {
82
+ // Helper to simulate what happens in Create()
83
+ function simulateCreate() {
84
+ return captureDefinitionInfo()
85
+ }
86
+
87
+ const info = simulateCreate()
88
+
89
+ // Should have captured the location of the simulateCreate() call
90
+ expect(info).toBeDefined()
91
+ expect(info.definitionStack).toBeDefined()
92
+ })
93
+ })
94
+ })
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Represents a specific location in source code where a procedure was defined.
3
+ */
4
+ export type DefinitionLocation = {
5
+ file: string
6
+ line: number
7
+ column: number
8
+ raw: string
9
+ }
10
+
11
+ /**
12
+ * Contains information about where a procedure was defined.
13
+ */
14
+ export type DefinitionInfo = {
15
+ definedAt?: DefinitionLocation
16
+ definitionStack?: string
17
+ }
18
+
19
+ /**
20
+ * Internal ts-procedures files that should be skipped when finding user code.
21
+ * Only skip the core library files, not test files or user code.
22
+ */
23
+ const INTERNAL_FILES = [
24
+ '/index.ts',
25
+ '/index.js',
26
+ '/errors.ts',
27
+ '/errors.js',
28
+ '/stack-utils.ts',
29
+ '/stack-utils.js',
30
+ '/compute-schema.ts',
31
+ '/compute-schema.js',
32
+ '/parser.ts',
33
+ '/parser.js',
34
+ ]
35
+
36
+ /**
37
+ * Captures the stack trace at the call site and extracts the definition location.
38
+ * Finds the first stack frame outside of ts-procedures internal files.
39
+ */
40
+ export function captureDefinitionInfo(): DefinitionInfo {
41
+ const err = new Error()
42
+ const stack = err.stack
43
+
44
+ if (!stack) {
45
+ return {}
46
+ }
47
+
48
+ const lines = stack.split('\n')
49
+
50
+ // Find the first frame that's not from ts-procedures internals
51
+ // Skip the first line (Error message) and frames from this module
52
+ let userFrame: string | undefined
53
+
54
+ for (let i = 1; i < lines.length; i++) {
55
+ const rawLine = lines[i]
56
+ if (!rawLine) continue
57
+ const line = rawLine.trim()
58
+
59
+ // Skip empty or invalid frames
60
+ if (!line.startsWith('at ')) {
61
+ continue
62
+ }
63
+
64
+ // Skip frames from ts-procedures internal source files
65
+ const isInternalFile = INTERNAL_FILES.some(file => line.includes(file))
66
+ if (isInternalFile) {
67
+ continue
68
+ }
69
+
70
+ // Skip frames from ts-procedures in node_modules (when used as a dependency)
71
+ if (line.includes('/node_modules/ts-procedures/') || line.includes('\\node_modules\\ts-procedures\\')) {
72
+ continue
73
+ }
74
+
75
+ // Skip internal node frames
76
+ if (line.includes('node:') || line.startsWith('at Module.') || line.startsWith('at Object.<anonymous> (node:')) {
77
+ continue
78
+ }
79
+
80
+ userFrame = line
81
+ break
82
+ }
83
+
84
+ if (!userFrame) {
85
+ return { definitionStack: stack }
86
+ }
87
+
88
+ const definedAt = parseStackFrame(userFrame)
89
+
90
+ return {
91
+ definedAt,
92
+ definitionStack: stack,
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Parses a V8 stack frame line to extract file, line, and column info.
98
+ * Handles formats like:
99
+ * - "at Object.<anonymous> (/path/to/file.ts:10:5)"
100
+ * - "at functionName (/path/to/file.ts:10:5)"
101
+ * - "at /path/to/file.ts:10:5"
102
+ */
103
+ function parseStackFrame(frame: string): DefinitionLocation | undefined {
104
+ // Match patterns like "(path:line:column)" or just "path:line:column"
105
+ const match = frame.match(/\(([^)]+):(\d+):(\d+)\)$/) || frame.match(/at ([^:]+):(\d+):(\d+)$/)
106
+
107
+ if (match && match[1] && match[2] && match[3]) {
108
+ return {
109
+ file: match[1],
110
+ line: parseInt(match[2], 10),
111
+ column: parseInt(match[3], 10),
112
+ raw: frame,
113
+ }
114
+ }
115
+
116
+ return undefined
117
+ }
118
+
119
+ /**
120
+ * Formats definition info for appending to error stacks.
121
+ */
122
+ export function formatDefinitionInfo(info: DefinitionInfo, procedureName: string): string | undefined {
123
+ if (!info.definedAt) {
124
+ return undefined
125
+ }
126
+
127
+ const { file, line, column } = info.definedAt
128
+ return `\n--- Procedure "${procedureName}" defined at ---\n at ${file}:${line}:${column}`
129
+ }