@pikku/inspector 0.12.10 → 0.12.12
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/CHANGELOG.md +21 -9
- package/dist/add/add-cli.js +10 -3
- package/dist/add/add-credential.js +2 -1
- package/dist/add/add-functions.js +99 -5
- package/dist/add/add-http-route.js +44 -6
- package/dist/add/add-keyed-wiring.js +3 -1
- package/dist/add/add-middleware.js +33 -4
- package/dist/add/add-permission.js +7 -7
- package/dist/add/add-workflow-graph.js +20 -1
- package/dist/error-codes.d.ts +2 -0
- package/dist/error-codes.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/inspector.js +2 -5
- package/dist/types.d.ts +10 -19
- package/dist/utils/extract-function-name.js +6 -0
- package/dist/utils/filter-inspector-state.js +187 -59
- package/dist/utils/filter-utils.js +13 -5
- package/dist/utils/get-property-value.d.ts +10 -0
- package/dist/utils/get-property-value.js +30 -0
- package/dist/utils/post-process.d.ts +2 -3
- package/dist/utils/post-process.js +3 -23
- package/dist/utils/resolve-addon-package.d.ts +4 -5
- package/dist/utils/resolve-addon-package.js +64 -16
- package/dist/utils/resolve-deploy-target.d.ts +28 -0
- package/dist/utils/resolve-deploy-target.js +56 -0
- package/dist/utils/resolve-versions.js +79 -0
- package/dist/utils/schema-generator.js +31 -12
- package/dist/utils/validate-auth-sessionless.d.ts +1 -1
- package/package.json +2 -2
- package/src/add/add-cli.ts +10 -3
- package/src/add/add-credential.ts +3 -0
- package/src/add/add-functions.test.ts +318 -0
- package/src/add/add-functions.ts +164 -6
- package/src/add/add-gateway.ts +5 -1
- package/src/add/add-http-route.ts +54 -7
- package/src/add/add-keyed-wiring.ts +7 -1
- package/src/add/add-mcp-prompt.ts +5 -1
- package/src/add/add-mcp-resource.ts +5 -1
- package/src/add/add-middleware.ts +42 -4
- package/src/add/add-permission.ts +7 -7
- package/src/add/add-schedule.ts +5 -1
- package/src/add/add-workflow-graph.ts +19 -1
- package/src/add/wire-name-literal.test.ts +114 -0
- package/src/error-codes.ts +2 -0
- package/src/index.ts +1 -0
- package/src/inspector.ts +1 -5
- package/src/types.ts +19 -15
- package/src/utils/extract-function-name.ts +8 -0
- package/src/utils/filter-inspector-state.test.ts +168 -64
- package/src/utils/filter-inspector-state.ts +290 -64
- package/src/utils/filter-utils.test.ts +30 -15
- package/src/utils/filter-utils.ts +14 -5
- package/src/utils/get-property-value.ts +40 -0
- package/src/utils/post-process.ts +3 -38
- package/src/utils/resolve-addon-package.ts +65 -14
- package/src/utils/resolve-deploy-target.test.ts +105 -0
- package/src/utils/resolve-deploy-target.ts +63 -0
- package/src/utils/resolve-versions.test.ts +108 -0
- package/src/utils/resolve-versions.ts +86 -0
- package/src/utils/schema-generator.ts +37 -13
- package/src/utils/validate-auth-sessionless.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { inspect } from '../inspector.js'
|
|
7
|
+
import { ErrorCode } from '../error-codes.js'
|
|
8
|
+
import type { InspectorLogger } from '../types.js'
|
|
9
|
+
|
|
10
|
+
describe('addFunctions duplicate name handling', () => {
|
|
11
|
+
test('logs a critical error when function name is duplicated across files', async () => {
|
|
12
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-duplicate-function-'))
|
|
13
|
+
const fileA = join(rootDir, 'a.ts')
|
|
14
|
+
const fileB = join(rootDir, 'b.ts')
|
|
15
|
+
|
|
16
|
+
await writeFile(
|
|
17
|
+
fileA,
|
|
18
|
+
[
|
|
19
|
+
"import { pikkuFunc } from '@pikku/core'",
|
|
20
|
+
'export const createUser = pikkuFunc({',
|
|
21
|
+
' func: async () => ({ ok: true })',
|
|
22
|
+
'})',
|
|
23
|
+
].join('\n')
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
await writeFile(
|
|
27
|
+
fileB,
|
|
28
|
+
[
|
|
29
|
+
"import { pikkuFunc } from '@pikku/core'",
|
|
30
|
+
'export const createUser = pikkuFunc({',
|
|
31
|
+
' func: async () => ({ ok: true })',
|
|
32
|
+
'})',
|
|
33
|
+
].join('\n')
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
37
|
+
const logger: InspectorLogger = {
|
|
38
|
+
debug: () => {},
|
|
39
|
+
info: () => {},
|
|
40
|
+
warn: () => {},
|
|
41
|
+
error: () => {},
|
|
42
|
+
critical: (code: ErrorCode, message: string) => {
|
|
43
|
+
criticals.push({ code, message })
|
|
44
|
+
},
|
|
45
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const state = await inspect(logger, [fileA, fileB], { rootDir })
|
|
50
|
+
const nameCollision = criticals.find(
|
|
51
|
+
(entry) => entry.code === ErrorCode.DUPLICATE_FUNCTION_NAME
|
|
52
|
+
)
|
|
53
|
+
assert.ok(nameCollision)
|
|
54
|
+
assert.match(nameCollision!.message, /createUser/)
|
|
55
|
+
assert.strictEqual(state.rpc.internalMeta['createUser'], 'createUser')
|
|
56
|
+
} finally {
|
|
57
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('allows same base function name across files when versions differ', async () => {
|
|
62
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-versioned-function-'))
|
|
63
|
+
const fileA = join(rootDir, 'a.ts')
|
|
64
|
+
const fileB = join(rootDir, 'b.ts')
|
|
65
|
+
|
|
66
|
+
await writeFile(
|
|
67
|
+
fileA,
|
|
68
|
+
[
|
|
69
|
+
"import { pikkuFunc } from '@pikku/core'",
|
|
70
|
+
'export const createUser = pikkuFunc({',
|
|
71
|
+
' version: 1,',
|
|
72
|
+
' func: async () => ({ ok: true })',
|
|
73
|
+
'})',
|
|
74
|
+
].join('\n')
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
await writeFile(
|
|
78
|
+
fileB,
|
|
79
|
+
[
|
|
80
|
+
"import { pikkuFunc } from '@pikku/core'",
|
|
81
|
+
'export const createUser = pikkuFunc({',
|
|
82
|
+
' version: 2,',
|
|
83
|
+
' func: async () => ({ ok: true })',
|
|
84
|
+
'})',
|
|
85
|
+
].join('\n')
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
89
|
+
const logger: InspectorLogger = {
|
|
90
|
+
debug: () => {},
|
|
91
|
+
info: () => {},
|
|
92
|
+
warn: () => {},
|
|
93
|
+
error: () => {},
|
|
94
|
+
critical: (code: ErrorCode, message: string) => {
|
|
95
|
+
criticals.push({ code, message })
|
|
96
|
+
},
|
|
97
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const state = await inspect(logger, [fileA, fileB], { rootDir })
|
|
102
|
+
const nameCollision = criticals.find(
|
|
103
|
+
(entry) => entry.code === ErrorCode.DUPLICATE_FUNCTION_NAME
|
|
104
|
+
)
|
|
105
|
+
assert.equal(nameCollision, undefined)
|
|
106
|
+
assert.strictEqual(state.rpc.internalMeta['createUser'], 'createUser@v2')
|
|
107
|
+
assert.ok(state.functions.meta['createUser@v1'])
|
|
108
|
+
assert.ok(state.functions.meta['createUser@v2'])
|
|
109
|
+
} finally {
|
|
110
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('strips VN suffix so createUserV1 + version:1 groups with createUser as createUser@v1', async () => {
|
|
115
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-vn-suffix-'))
|
|
116
|
+
const fileV1 = join(rootDir, 'create-user-v1.ts')
|
|
117
|
+
const fileLatest = join(rootDir, 'create-user.ts')
|
|
118
|
+
|
|
119
|
+
await writeFile(
|
|
120
|
+
fileV1,
|
|
121
|
+
[
|
|
122
|
+
"import { pikkuFunc } from '@pikku/core'",
|
|
123
|
+
'export const createUserV1 = pikkuFunc({',
|
|
124
|
+
' version: 1,',
|
|
125
|
+
' func: async () => ({ ok: true })',
|
|
126
|
+
'})',
|
|
127
|
+
].join('\n')
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
await writeFile(
|
|
131
|
+
fileLatest,
|
|
132
|
+
[
|
|
133
|
+
"import { pikkuFunc } from '@pikku/core'",
|
|
134
|
+
'export const createUser = pikkuFunc({',
|
|
135
|
+
' func: async () => ({ ok: true })',
|
|
136
|
+
'})',
|
|
137
|
+
].join('\n')
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
const logger: InspectorLogger = {
|
|
141
|
+
debug: () => {},
|
|
142
|
+
info: () => {},
|
|
143
|
+
warn: () => {},
|
|
144
|
+
error: () => {},
|
|
145
|
+
critical: () => {},
|
|
146
|
+
hasCriticalErrors: () => false,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const state = await inspect(logger, [fileV1, fileLatest], { rootDir })
|
|
151
|
+
// V1 suffix stripped: createUserV1 + version:1 → createUser@v1
|
|
152
|
+
assert.ok(
|
|
153
|
+
state.functions.meta['createUser@v1'],
|
|
154
|
+
'createUser@v1 should exist'
|
|
155
|
+
)
|
|
156
|
+
assert.strictEqual(state.functions.meta['createUser@v1']!.version, 1)
|
|
157
|
+
// Unversioned createUser auto-promoted to createUser@v2
|
|
158
|
+
assert.ok(
|
|
159
|
+
state.functions.meta['createUser@v2'],
|
|
160
|
+
'createUser@v2 should exist'
|
|
161
|
+
)
|
|
162
|
+
assert.strictEqual(state.functions.meta['createUser@v2']!.version, 2)
|
|
163
|
+
// No stale createUserV1@v1 entry
|
|
164
|
+
assert.strictEqual(state.functions.meta['createUserV1@v1'], undefined)
|
|
165
|
+
// Latest alias points to v2
|
|
166
|
+
assert.strictEqual(state.rpc.internalMeta['createUser'], 'createUser@v2')
|
|
167
|
+
} finally {
|
|
168
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('logs a critical error when exposed function name is duplicated across files', async () => {
|
|
173
|
+
const rootDir = await mkdtemp(
|
|
174
|
+
join(tmpdir(), 'pikku-exposed-duplicate-function-')
|
|
175
|
+
)
|
|
176
|
+
const fileA = join(rootDir, 'a.ts')
|
|
177
|
+
const fileB = join(rootDir, 'b.ts')
|
|
178
|
+
|
|
179
|
+
await writeFile(
|
|
180
|
+
fileA,
|
|
181
|
+
[
|
|
182
|
+
"import { pikkuFunc } from '@pikku/core'",
|
|
183
|
+
'export const createUser = pikkuFunc({',
|
|
184
|
+
' expose: true,',
|
|
185
|
+
' func: async () => ({ ok: true })',
|
|
186
|
+
'})',
|
|
187
|
+
].join('\n')
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
await writeFile(
|
|
191
|
+
fileB,
|
|
192
|
+
[
|
|
193
|
+
"import { pikkuFunc } from '@pikku/core'",
|
|
194
|
+
'export const createUser = pikkuFunc({',
|
|
195
|
+
' expose: true,',
|
|
196
|
+
' func: async () => ({ ok: true })',
|
|
197
|
+
'})',
|
|
198
|
+
].join('\n')
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
202
|
+
const logger: InspectorLogger = {
|
|
203
|
+
debug: () => {},
|
|
204
|
+
info: () => {},
|
|
205
|
+
warn: () => {},
|
|
206
|
+
error: () => {},
|
|
207
|
+
critical: (code: ErrorCode, message: string) => {
|
|
208
|
+
criticals.push({ code, message })
|
|
209
|
+
},
|
|
210
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
await inspect(logger, [fileA, fileB], { rootDir })
|
|
215
|
+
const nameCollision = criticals.find(
|
|
216
|
+
(entry) => entry.code === ErrorCode.DUPLICATE_FUNCTION_NAME
|
|
217
|
+
)
|
|
218
|
+
assert.ok(nameCollision)
|
|
219
|
+
assert.match(
|
|
220
|
+
nameCollision!.message,
|
|
221
|
+
/Function name 'createUser' is not unique/
|
|
222
|
+
)
|
|
223
|
+
} finally {
|
|
224
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
describe('addFunctions implementationHash', () => {
|
|
230
|
+
test('records a stable implementation hash for an inline function', async () => {
|
|
231
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-function-hash-inline-'))
|
|
232
|
+
const file = join(rootDir, 'inline.ts')
|
|
233
|
+
|
|
234
|
+
await writeFile(
|
|
235
|
+
file,
|
|
236
|
+
[
|
|
237
|
+
"import { pikkuFunc } from '@pikku/core'",
|
|
238
|
+
'export const createUser = pikkuFunc({',
|
|
239
|
+
' expose: true,',
|
|
240
|
+
' func: async ({ logger }, input: { name: string }) => {',
|
|
241
|
+
" logger.info('create user')",
|
|
242
|
+
' return { ok: true, name: input.name }',
|
|
243
|
+
' }',
|
|
244
|
+
'})',
|
|
245
|
+
].join('\n')
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
const logger: InspectorLogger = {
|
|
249
|
+
debug: () => {},
|
|
250
|
+
info: () => {},
|
|
251
|
+
warn: () => {},
|
|
252
|
+
error: () => {},
|
|
253
|
+
critical: () => {},
|
|
254
|
+
hasCriticalErrors: () => false,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const first = await inspect(logger, [file], { rootDir })
|
|
259
|
+
const second = await inspect(logger, [file], { rootDir })
|
|
260
|
+
const firstHash = first.functions.meta['createUser']?.implementationHash
|
|
261
|
+
const secondHash = second.functions.meta['createUser']?.implementationHash
|
|
262
|
+
|
|
263
|
+
assert.match(firstHash ?? '', /^[0-9a-f]{16}$/)
|
|
264
|
+
assert.strictEqual(firstHash, secondHash)
|
|
265
|
+
} finally {
|
|
266
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('changes when a referenced handler implementation changes', async () => {
|
|
271
|
+
const rootDir = await mkdtemp(
|
|
272
|
+
join(tmpdir(), 'pikku-function-hash-referenced-')
|
|
273
|
+
)
|
|
274
|
+
const file = join(rootDir, 'referenced.ts')
|
|
275
|
+
|
|
276
|
+
const writeSource = async (bodyLine: string) => {
|
|
277
|
+
await writeFile(
|
|
278
|
+
file,
|
|
279
|
+
[
|
|
280
|
+
"import { pikkuFunc } from '@pikku/core'",
|
|
281
|
+
'',
|
|
282
|
+
'const handler = async () => {',
|
|
283
|
+
` ${bodyLine}`,
|
|
284
|
+
' return { ok: true }',
|
|
285
|
+
'}',
|
|
286
|
+
'',
|
|
287
|
+
'export const createUser = pikkuFunc({',
|
|
288
|
+
' func: handler,',
|
|
289
|
+
'})',
|
|
290
|
+
].join('\n')
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const logger: InspectorLogger = {
|
|
295
|
+
debug: () => {},
|
|
296
|
+
info: () => {},
|
|
297
|
+
warn: () => {},
|
|
298
|
+
error: () => {},
|
|
299
|
+
critical: () => {},
|
|
300
|
+
hasCriticalErrors: () => false,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
await writeSource("console.log('first')")
|
|
305
|
+
const first = await inspect(logger, [file], { rootDir })
|
|
306
|
+
|
|
307
|
+
await writeSource("console.log('second')")
|
|
308
|
+
const second = await inspect(logger, [file], { rootDir })
|
|
309
|
+
|
|
310
|
+
assert.notStrictEqual(
|
|
311
|
+
first.functions.meta['createUser']?.implementationHash,
|
|
312
|
+
second.functions.meta['createUser']?.implementationHash
|
|
313
|
+
)
|
|
314
|
+
} finally {
|
|
315
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
})
|
package/src/add/add-functions.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as ts from 'typescript'
|
|
2
|
-
import type { AddWiring, SchemaRef } from '../types.js'
|
|
2
|
+
import type { AddWiring, InspectorState, SchemaRef } from '../types.js'
|
|
3
3
|
import { detectSchemaVendorOrError } from '../utils/detect-schema-vendor.js'
|
|
4
4
|
import type { TypesMap } from '../types-map.js'
|
|
5
5
|
import {
|
|
@@ -9,11 +9,12 @@ import {
|
|
|
9
9
|
import { extractFunctionNode } from '../utils/extract-function-node.js'
|
|
10
10
|
import { extractUsedWires } from '../utils/extract-services.js'
|
|
11
11
|
import type { FunctionServicesMeta } from '@pikku/core'
|
|
12
|
-
import { formatVersionedId } from '@pikku/core'
|
|
12
|
+
import { formatVersionedId, parseVersionedId } from '@pikku/core'
|
|
13
13
|
import {
|
|
14
14
|
getPropertyValue,
|
|
15
15
|
getCommonWireMetaData,
|
|
16
16
|
} from '../utils/get-property-value.js'
|
|
17
|
+
import { canonicalJSON, hashString } from '../utils/hash.js'
|
|
17
18
|
import { resolveMiddleware } from '../utils/middleware.js'
|
|
18
19
|
import { resolvePermissions } from '../utils/permissions.js'
|
|
19
20
|
import { extractWireNames } from '../utils/post-process.js'
|
|
@@ -287,6 +288,54 @@ function unwrapPromise(checker: ts.TypeChecker, type: ts.Type): ts.Type {
|
|
|
287
288
|
return type
|
|
288
289
|
}
|
|
289
290
|
|
|
291
|
+
const resolveExistingFunctionSource = (
|
|
292
|
+
state: InspectorState,
|
|
293
|
+
pikkuFuncId: string
|
|
294
|
+
): string | null => {
|
|
295
|
+
return (
|
|
296
|
+
state.functions.meta[pikkuFuncId]?.sourceFile ||
|
|
297
|
+
state.rpc.internalFiles.get(pikkuFuncId)?.path ||
|
|
298
|
+
null
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const areCompatibleFunctionIds = (
|
|
303
|
+
existingId: string,
|
|
304
|
+
incomingId: string
|
|
305
|
+
): boolean => {
|
|
306
|
+
if (existingId === incomingId) {
|
|
307
|
+
return true
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const existingParsed = parseVersionedId(existingId)
|
|
311
|
+
const incomingParsed = parseVersionedId(incomingId)
|
|
312
|
+
|
|
313
|
+
return existingParsed.baseName === incomingParsed.baseName
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function printNode(node: ts.Node): string {
|
|
317
|
+
return ts
|
|
318
|
+
.createPrinter({ removeComments: true })
|
|
319
|
+
.printNode(ts.EmitHint.Unspecified, node, node.getSourceFile())
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function computeImplementationHash(args: {
|
|
323
|
+
wrapper: string
|
|
324
|
+
handler: ts.ArrowFunction | ts.FunctionExpression
|
|
325
|
+
objectNode?: ts.ObjectLiteralExpression
|
|
326
|
+
isDirectFunction: boolean
|
|
327
|
+
}): string {
|
|
328
|
+
const { wrapper, handler, objectNode, isDirectFunction } = args
|
|
329
|
+
return hashString(
|
|
330
|
+
canonicalJSON({
|
|
331
|
+
wrapper,
|
|
332
|
+
isDirectFunction,
|
|
333
|
+
handler: printNode(handler),
|
|
334
|
+
config: objectNode ? printNode(objectNode) : null,
|
|
335
|
+
})
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
290
339
|
/**
|
|
291
340
|
* Inspect pikkuFunc calls, extract input/output and first-arg destructuring,
|
|
292
341
|
* then push into state.functions.meta.
|
|
@@ -541,11 +590,17 @@ export const addFunctions: AddWiring = (
|
|
|
541
590
|
}
|
|
542
591
|
|
|
543
592
|
if (version !== undefined) {
|
|
544
|
-
|
|
593
|
+
let baseName = explicitName || exportedName || pikkuFuncId
|
|
594
|
+
// Strip trailing VN suffix if it matches the version (e.g. getDataV1 + version:1 → getData@v1)
|
|
595
|
+
const vSuffix = `V${version}`
|
|
596
|
+
if (baseName.endsWith(vSuffix) && baseName.length > vSuffix.length) {
|
|
597
|
+
baseName = baseName.slice(0, -vSuffix.length)
|
|
598
|
+
}
|
|
545
599
|
pikkuFuncId = formatVersionedId(baseName, version)
|
|
546
600
|
}
|
|
547
601
|
|
|
548
602
|
const isMCPToolFunc = expression.text === 'pikkuMCPToolFunc'
|
|
603
|
+
const isListFunc = expression.text === 'pikkuListFunc'
|
|
549
604
|
const mcpEnabled = mcp || isMCPToolFunc
|
|
550
605
|
|
|
551
606
|
// Pick the handler: use resolvedFunc when it exists and is a function, otherwise fall back to handlerNode
|
|
@@ -618,7 +673,33 @@ export const addFunctions: AddWiring = (
|
|
|
618
673
|
inputNames = [schemaName]
|
|
619
674
|
state.schemaLookup.set(schemaName, inputSchemaRef)
|
|
620
675
|
state.functions.typesMap.addCustomType(schemaName, 'unknown', [])
|
|
621
|
-
} else if (genericTypes.length >= 1 && genericTypes[0]) {
|
|
676
|
+
} else if (isListFunc && genericTypes.length >= 1 && genericTypes[0]) {
|
|
677
|
+
const inputAliasName = `${capitalizedName}Input`
|
|
678
|
+
const filterType = genericTypes[0]
|
|
679
|
+
const filterTypeText = checker.typeToString(
|
|
680
|
+
filterType,
|
|
681
|
+
undefined,
|
|
682
|
+
ts.TypeFormatFlags.NoTruncation
|
|
683
|
+
)
|
|
684
|
+
const refs = resolveTypeImports(
|
|
685
|
+
filterType,
|
|
686
|
+
state.functions.typesMap,
|
|
687
|
+
true,
|
|
688
|
+
checker
|
|
689
|
+
)
|
|
690
|
+
state.functions.typesMap.addCustomType(
|
|
691
|
+
inputAliasName,
|
|
692
|
+
`{ cursor?: string; limit?: number; sort?: Array<{ column: string; direction: 'asc' | 'desc' }>; filter?: unknown; search?: string; } & ${filterTypeText}`,
|
|
693
|
+
[...new Set(refs)]
|
|
694
|
+
)
|
|
695
|
+
inputNames = [inputAliasName]
|
|
696
|
+
const secondParam = handler.parameters[1]
|
|
697
|
+
if (secondParam) {
|
|
698
|
+
inputTypes = [checker.getTypeAtLocation(secondParam)]
|
|
699
|
+
} else {
|
|
700
|
+
inputTypes = [filterType]
|
|
701
|
+
}
|
|
702
|
+
} else if (!isListFunc && genericTypes.length >= 1 && genericTypes[0]) {
|
|
622
703
|
// Fall back to extracting from generic type arguments
|
|
623
704
|
const result = getNamesAndTypes(
|
|
624
705
|
checker,
|
|
@@ -654,7 +735,27 @@ export const addFunctions: AddWiring = (
|
|
|
654
735
|
outputNames = [schemaName]
|
|
655
736
|
state.schemaLookup.set(schemaName, outputSchemaRef)
|
|
656
737
|
state.functions.typesMap.addCustomType(schemaName, 'unknown', [])
|
|
657
|
-
} else if (genericTypes.length >= 2) {
|
|
738
|
+
} else if (isListFunc && genericTypes.length >= 2 && genericTypes[1]) {
|
|
739
|
+
const outputAliasName = `${capitalizedName}Output`
|
|
740
|
+
const rowType = genericTypes[1]
|
|
741
|
+
const rowTypeText = checker.typeToString(
|
|
742
|
+
rowType,
|
|
743
|
+
undefined,
|
|
744
|
+
ts.TypeFormatFlags.NoTruncation
|
|
745
|
+
)
|
|
746
|
+
const refs = resolveTypeImports(
|
|
747
|
+
rowType,
|
|
748
|
+
state.functions.typesMap,
|
|
749
|
+
true,
|
|
750
|
+
checker
|
|
751
|
+
)
|
|
752
|
+
state.functions.typesMap.addCustomType(
|
|
753
|
+
outputAliasName,
|
|
754
|
+
`{ rows: Array<${rowTypeText}>; nextCursor: string | null; totalCount?: number; }`,
|
|
755
|
+
[...new Set(refs)]
|
|
756
|
+
)
|
|
757
|
+
outputNames = [outputAliasName]
|
|
758
|
+
} else if (!isListFunc && genericTypes.length >= 2) {
|
|
658
759
|
outputNames = getNamesAndTypes(
|
|
659
760
|
checker,
|
|
660
761
|
state.functions.typesMap,
|
|
@@ -731,6 +832,56 @@ export const addFunctions: AddWiring = (
|
|
|
731
832
|
state.typesLookup.set(pikkuFuncId, inputTypes)
|
|
732
833
|
}
|
|
733
834
|
|
|
835
|
+
const sourceFile = node.getSourceFile().fileName
|
|
836
|
+
const existingFunction = state.functions.meta[pikkuFuncId]
|
|
837
|
+
if (
|
|
838
|
+
existingFunction &&
|
|
839
|
+
existingFunction.sourceFile &&
|
|
840
|
+
existingFunction.sourceFile !== sourceFile
|
|
841
|
+
) {
|
|
842
|
+
logger.critical(
|
|
843
|
+
ErrorCode.DUPLICATE_FUNCTION_NAME,
|
|
844
|
+
`Function name '${name}' is not unique. ` +
|
|
845
|
+
`'${pikkuFuncId}' is already defined in '${existingFunction.sourceFile}' and cannot be redefined in '${sourceFile}'.`
|
|
846
|
+
)
|
|
847
|
+
return
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (exportedName || explicitName) {
|
|
851
|
+
const existingInternal = state.rpc.internalMeta[name]
|
|
852
|
+
if (
|
|
853
|
+
existingInternal &&
|
|
854
|
+
!areCompatibleFunctionIds(existingInternal, pikkuFuncId)
|
|
855
|
+
) {
|
|
856
|
+
const existingSource =
|
|
857
|
+
resolveExistingFunctionSource(state, existingInternal) || 'unknown file'
|
|
858
|
+
logger.critical(
|
|
859
|
+
ErrorCode.DUPLICATE_FUNCTION_NAME,
|
|
860
|
+
`Function name '${name}' is not unique. ` +
|
|
861
|
+
`It already points to '${existingInternal}' in '${existingSource}', but '${pikkuFuncId}' in '${sourceFile}' tried to use the same name.`
|
|
862
|
+
)
|
|
863
|
+
return
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (expose) {
|
|
867
|
+
const existingExposed = state.rpc.exposedMeta[name]
|
|
868
|
+
if (
|
|
869
|
+
existingExposed &&
|
|
870
|
+
!areCompatibleFunctionIds(existingExposed, pikkuFuncId)
|
|
871
|
+
) {
|
|
872
|
+
const existingSource =
|
|
873
|
+
resolveExistingFunctionSource(state, existingExposed) ||
|
|
874
|
+
'unknown file'
|
|
875
|
+
logger.critical(
|
|
876
|
+
ErrorCode.DUPLICATE_FUNCTION_NAME,
|
|
877
|
+
`Exposed function name '${name}' is not unique. ` +
|
|
878
|
+
`It already points to '${existingExposed}' in '${existingSource}', but '${pikkuFuncId}' in '${sourceFile}' tried to use the same name.`
|
|
879
|
+
)
|
|
880
|
+
return
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
734
885
|
// --- resolve middleware ---
|
|
735
886
|
let middleware = objectNode
|
|
736
887
|
? resolveMiddleware(state, objectNode, tags, checker)
|
|
@@ -772,6 +923,12 @@ export const addFunctions: AddWiring = (
|
|
|
772
923
|
}
|
|
773
924
|
|
|
774
925
|
const sessionless = expression.text !== 'pikkuFunc'
|
|
926
|
+
const implementationHash = computeImplementationHash({
|
|
927
|
+
wrapper: expression.text,
|
|
928
|
+
handler,
|
|
929
|
+
objectNode,
|
|
930
|
+
isDirectFunction,
|
|
931
|
+
})
|
|
775
932
|
|
|
776
933
|
state.functions.meta[pikkuFuncId] = {
|
|
777
934
|
pikkuFuncId,
|
|
@@ -792,6 +949,7 @@ export const addFunctions: AddWiring = (
|
|
|
792
949
|
deploy: deploy || undefined,
|
|
793
950
|
approvalRequired: approvalRequired || undefined,
|
|
794
951
|
approvalDescription: approvalDescription || undefined,
|
|
952
|
+
implementationHash,
|
|
795
953
|
version,
|
|
796
954
|
title,
|
|
797
955
|
tags: tags || undefined,
|
|
@@ -801,7 +959,7 @@ export const addFunctions: AddWiring = (
|
|
|
801
959
|
middleware,
|
|
802
960
|
permissions,
|
|
803
961
|
isDirectFunction,
|
|
804
|
-
sourceFile
|
|
962
|
+
sourceFile,
|
|
805
963
|
exportedName: exportedName || undefined,
|
|
806
964
|
}
|
|
807
965
|
|
package/src/add/add-gateway.ts
CHANGED
|
@@ -70,7 +70,11 @@ export const addGateway: AddWiring = (
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
const packageName = ts.isIdentifier(funcInitializer)
|
|
73
|
-
? resolveAddonName(
|
|
73
|
+
? resolveAddonName(
|
|
74
|
+
funcInitializer,
|
|
75
|
+
checker,
|
|
76
|
+
state.rpc.wireAddonDeclarations
|
|
77
|
+
)
|
|
74
78
|
: null
|
|
75
79
|
|
|
76
80
|
if (!nameValue || !typeValue) {
|
|
@@ -18,6 +18,7 @@ import { resolveHTTPMiddlewareFromObject } from '../utils/middleware.js'
|
|
|
18
18
|
import { resolveHTTPPermissionsFromObject } from '../utils/permissions.js'
|
|
19
19
|
import { extractWireNames } from '../utils/post-process.js'
|
|
20
20
|
import { ensureFunctionMetadata } from '../utils/ensure-function-metadata.js'
|
|
21
|
+
import { resolveFunctionMeta } from '../utils/resolve-function-meta.js'
|
|
21
22
|
import { ErrorCode } from '../error-codes.js'
|
|
22
23
|
import { validateAuthSessionless } from '../utils/validate-auth-sessionless.js'
|
|
23
24
|
import { detectSchemaVendorOrError } from '../utils/detect-schema-vendor.js'
|
|
@@ -204,6 +205,22 @@ export function registerHTTPRoute({
|
|
|
204
205
|
funcName = makeContextBasedId('http', method, fullRoute)
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
let refAddonTarget: string | null = null
|
|
209
|
+
if (
|
|
210
|
+
ts.isCallExpression(funcInitializer) &&
|
|
211
|
+
ts.isIdentifier(funcInitializer.expression) &&
|
|
212
|
+
funcInitializer.expression.text === 'ref'
|
|
213
|
+
) {
|
|
214
|
+
const [firstArg] = funcInitializer.arguments
|
|
215
|
+
if (
|
|
216
|
+
firstArg &&
|
|
217
|
+
ts.isStringLiteral(firstArg) &&
|
|
218
|
+
firstArg.text.includes(':')
|
|
219
|
+
) {
|
|
220
|
+
refAddonTarget = firstArg.text
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
207
224
|
const packageName = ts.isIdentifier(funcInitializer)
|
|
208
225
|
? resolveAddonName(
|
|
209
226
|
funcInitializer,
|
|
@@ -212,6 +229,16 @@ export function registerHTTPRoute({
|
|
|
212
229
|
)
|
|
213
230
|
: null
|
|
214
231
|
|
|
232
|
+
if (refAddonTarget) {
|
|
233
|
+
const targetMeta = resolveFunctionMeta(state, refAddonTarget)
|
|
234
|
+
if (!targetMeta) {
|
|
235
|
+
logger.warn(
|
|
236
|
+
`Skipping route '${fullRoute}': addon function metadata for '${refAddonTarget}' is not available yet.`
|
|
237
|
+
)
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
215
242
|
ensureFunctionMetadata(
|
|
216
243
|
state,
|
|
217
244
|
funcName,
|
|
@@ -222,7 +249,7 @@ export function registerHTTPRoute({
|
|
|
222
249
|
)
|
|
223
250
|
|
|
224
251
|
// Lookup existing function metadata
|
|
225
|
-
const fnMeta = state
|
|
252
|
+
const fnMeta = resolveFunctionMeta(state, funcName)
|
|
226
253
|
if (!fnMeta) {
|
|
227
254
|
logger.critical(
|
|
228
255
|
ErrorCode.FUNCTION_METADATA_NOT_FOUND,
|
|
@@ -246,12 +273,32 @@ export function registerHTTPRoute({
|
|
|
246
273
|
|
|
247
274
|
const input = fnMeta.inputs?.[0] || null
|
|
248
275
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const inputTypes = state.typesLookup.get(
|
|
276
|
+
const getRouteInputKeys = (): string[] | null => {
|
|
277
|
+
const targetFuncName = refAddonTarget ?? funcName
|
|
278
|
+
const inputTypes = state.typesLookup.get(targetFuncName)
|
|
252
279
|
if (inputTypes && inputTypes.length > 0) {
|
|
253
|
-
|
|
280
|
+
return extractTypeKeys(inputTypes[0])
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const targetMeta = resolveFunctionMeta(state, targetFuncName)
|
|
284
|
+
if (targetMeta?.inputSchemaName) {
|
|
285
|
+
const schema = state.schemas[targetMeta.inputSchemaName] as any
|
|
286
|
+
const properties = schema?.properties
|
|
287
|
+
if (properties && typeof properties === 'object') {
|
|
288
|
+
return Object.keys(properties)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
254
291
|
|
|
292
|
+
return null
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Validate that route params and query params exist in function input type
|
|
296
|
+
if (params.length > 0 || query.length > 0) {
|
|
297
|
+
const inputKeys = getRouteInputKeys()
|
|
298
|
+
if (!inputKeys) {
|
|
299
|
+
// Input shape isn't inspectable at this phase (e.g. addon ref or opaque handler).
|
|
300
|
+
// Skip param/query validation rather than emitting a false positive.
|
|
301
|
+
} else {
|
|
255
302
|
// Check path params
|
|
256
303
|
if (params.length > 0) {
|
|
257
304
|
const missingParams = params.filter((p) => !inputKeys.includes(p))
|
|
@@ -259,7 +306,7 @@ export function registerHTTPRoute({
|
|
|
259
306
|
logger.critical(
|
|
260
307
|
ErrorCode.ROUTE_PARAM_MISMATCH,
|
|
261
308
|
`Route '${fullRoute}' has path parameter(s) [${missingParams.join(', ')}] ` +
|
|
262
|
-
`not found in function '${funcName}' input type. ` +
|
|
309
|
+
`not found in function '${refAddonTarget ?? funcName}' input type. ` +
|
|
263
310
|
`Input type has: [${inputKeys.join(', ')}]`
|
|
264
311
|
)
|
|
265
312
|
return
|
|
@@ -273,7 +320,7 @@ export function registerHTTPRoute({
|
|
|
273
320
|
logger.critical(
|
|
274
321
|
ErrorCode.ROUTE_QUERY_MISMATCH,
|
|
275
322
|
`Route '${fullRoute}' has query parameter(s) [${missingQuery.join(', ')}] ` +
|
|
276
|
-
`not found in function '${funcName}' input type. ` +
|
|
323
|
+
`not found in function '${refAddonTarget ?? funcName}' input type. ` +
|
|
277
324
|
`Input type has: [${inputKeys.join(', ')}]`
|
|
278
325
|
)
|
|
279
326
|
return
|